diff --git a/.circleci/e2e/test.app-image.sh b/.circleci/e2e/test.app-image.sh index 11b2f6c958..f666e17712 100755 --- a/.circleci/e2e/test.app-image.sh +++ b/.circleci/e2e/test.app-image.sh @@ -1,3 +1,6 @@ +#!/bin/bash +set -e + yarn --cwd tests/e2e install # mount app resources diff --git a/.circleci/redisstack/app-image.repack.sh b/.circleci/redisstack/app-image.repack.sh index 6a45c737b6..7c5821d1b1 100755 --- a/.circleci/redisstack/app-image.repack.sh +++ b/.circleci/redisstack/app-image.repack.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -e ARCH=${ARCH:-x86_64} WORKING_DIRECTORY=$(pwd) diff --git a/.circleci/redisstack/build.sh b/.circleci/redisstack/build.sh index 81f00ec651..39d6cc645e 100755 --- a/.circleci/redisstack/build.sh +++ b/.circleci/redisstack/build.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -e # install deps yarn diff --git a/.circleci/redisstack/build_modules.sh b/.circleci/redisstack/build_modules.sh index 4fb9747982..f6ff5c0f7b 100755 --- a/.circleci/redisstack/build_modules.sh +++ b/.circleci/redisstack/build_modules.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -e PLATFORM=${PLATFORM:-'linux'} ELECTRON_VERSION=$(cat electron/version) diff --git a/.circleci/redisstack/dmg.repack.sh b/.circleci/redisstack/dmg.repack.sh index f204977e4b..4b4aa12043 100755 --- a/.circleci/redisstack/dmg.repack.sh +++ b/.circleci/redisstack/dmg.repack.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -e ARCH=${ARCH:-x64} WORKING_DIRECTORY=$(pwd) diff --git a/Dockerfile b/Dockerfile index 4e0b401607..98ba56dc36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,10 @@ COPY scripts ./scripts COPY redisinsight ./redisinsight RUN SKIP_POSTINSTALL=1 yarn install RUN yarn --cwd redisinsight/api +ARG SERVER_TLS_CERT +ARG SERVER_TLS_KEY +ENV SERVER_TLS_CERT=${SERVER_TLS_CERT} +ENV SERVER_TLS_KEY=${SERVER_TLS_KEY} RUN yarn build:web RUN yarn build:statics diff --git a/package.json b/package.json index 3ad9bc9e17..6384a0ae06 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:main:stage": "webpack --config ./configs/webpack.config.main.stage.babel.js", "build:web": "webpack --config ./configs/webpack.config.web.prod.babel.js", "build:defaults": "yarn --cwd redisinsight/api build:defaults", - "build:statics": "yarn build:defaults & sh ./scripts/build-statics.sh", - "build:statics:win": "yarn build:defaults & ./scripts/build-statics.cmd", + "build:statics": "yarn build:defaults && sh ./scripts/build-statics.sh", + "build:statics:win": "yarn build:defaults && ./scripts/build-statics.cmd", "build:renderer": "webpack --config ./configs/webpack.config.renderer.prod.babel.js", "build:renderer:stage": "webpack --config ./configs/webpack.config.renderer.stage.babel.js", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir redisinsight/ui", diff --git a/redisinsight/about-panel.ts b/redisinsight/about-panel.ts index cd5da210f2..67ea14fc5e 100644 --- a/redisinsight/about-panel.ts +++ b/redisinsight/about-panel.ts @@ -7,7 +7,7 @@ const ICON_PATH = app.isPackaged export default { applicationName: 'RedisInsight-v2', - applicationVersion: app.getVersion() || '2.0', + applicationVersion: app.getVersion() || '2.2.0', copyright: `Copyright © ${new Date().getFullYear()} Redis Ltd.`, iconPath: ICON_PATH, }; diff --git a/redisinsight/api/src/app.module.ts b/redisinsight/api/src/app.module.ts index 2987e8cd93..9ad75e0129 100644 --- a/redisinsight/api/src/app.module.ts +++ b/redisinsight/api/src/app.module.ts @@ -11,6 +11,7 @@ import config from 'src/utils/config'; import { PluginModule } from 'src/modules/plugin/plugin.module'; import { CommandsModule } from 'src/modules/commands/commands.module'; import { WorkbenchModule } from 'src/modules/workbench/workbench.module'; +import { SlowLogModule } from 'src/modules/slow-log/slow-log.module'; import { SharedModule } from './modules/shared/shared.module'; import { InstancesModule } from './modules/instances/instances.module'; import { BrowserModule } from './modules/browser/browser.module'; @@ -42,6 +43,7 @@ const PATH_CONFIG = config.get('dir_path'); PluginModule, CommandsModule, ProfilerModule, + SlowLogModule, EventEmitterModule.forRoot(), ...(SERVER_CONFIG.staticContent ? [ diff --git a/redisinsight/api/src/app.routes.ts b/redisinsight/api/src/app.routes.ts index d49843e4db..97ccd5e98f 100644 --- a/redisinsight/api/src/app.routes.ts +++ b/redisinsight/api/src/app.routes.ts @@ -5,6 +5,7 @@ import { RedisEnterpriseModule } from 'src/modules/redis-enterprise/redis-enterp import { RedisSentinelModule } from 'src/modules/redis-sentinel/redis-sentinel.module'; import { CliModule } from 'src/modules/cli/cli.module'; import { WorkbenchModule } from 'src/modules/workbench/workbench.module'; +import { SlowLogModule } from 'src/modules/slow-log/slow-log.module'; export const routes: Routes = [ { @@ -23,6 +24,10 @@ export const routes: Routes = [ path: '/:dbInstance', module: WorkbenchModule, }, + { + path: '/:dbInstance', + module: SlowLogModule, + }, ], }, { diff --git a/redisinsight/api/src/constants/telemetry-events.ts b/redisinsight/api/src/constants/telemetry-events.ts index 5be88bb3a9..bbeb4c4f60 100644 --- a/redisinsight/api/src/constants/telemetry-events.ts +++ b/redisinsight/api/src/constants/telemetry-events.ts @@ -41,4 +41,8 @@ export enum TelemetryEvents { // Profiler ProfilerLogDownloaded = 'PROFILER_LOG_DOWNLOADED', ProfilerLogDeleted = 'PROFILER_LOG_DELETED', + + // Slowlog + SlowlogSetLogSlowerThan = 'SLOWLOG_SET_LOG_SLOWER_THAN', + SlowlogSetMaxLen = 'SLOWLOG_SET_MAX_LEN', } diff --git a/redisinsight/api/src/modules/browser/browser.module.ts b/redisinsight/api/src/modules/browser/browser.module.ts index b65e8395b2..34a1d37850 100644 --- a/redisinsight/api/src/modules/browser/browser.module.ts +++ b/redisinsight/api/src/modules/browser/browser.module.ts @@ -2,6 +2,8 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { RouterModule } from 'nest-router'; import { SharedModule } from 'src/modules/shared/shared.module'; import { RedisConnectionMiddleware } from 'src/middleware/redis-connection.middleware'; +import { StreamController } from 'src/modules/browser/controllers/stream/stream.controller'; +import { StreamService } from 'src/modules/browser/services/stream/stream.service'; import { HashController } from './controllers/hash/hash.controller'; import { KeysController } from './controllers/keys/keys.controller'; import { KeysBusinessService } from './services/keys-business/keys-business.service'; @@ -29,6 +31,7 @@ import { BrowserToolClusterService } from './services/browser-tool-cluster/brows ZSetController, RejsonRlController, HashController, + StreamController, ], providers: [ KeysBusinessService, @@ -38,6 +41,7 @@ import { BrowserToolClusterService } from './services/browser-tool-cluster/brows ZSetBusinessService, RejsonRlBusinessService, HashBusinessService, + StreamService, BrowserToolService, BrowserToolClusterService, ], diff --git a/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts b/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts index 32bcf4fab7..8b8a1436c2 100644 --- a/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts +++ b/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts @@ -75,6 +75,11 @@ export enum BrowserToolGraphCommands { } export enum BrowserToolStreamCommands { XLen = 'xlen', + XInfoStream = 'xinfo stream', + XRange = 'xrange', + XRevRange = 'xrevrange', + XAdd = 'xadd', + XDel = 'xdel', } export enum BrowserToolTSCommands { diff --git a/redisinsight/api/src/modules/browser/controllers/stream/stream.controller.ts b/redisinsight/api/src/modules/browser/controllers/stream/stream.controller.ts new file mode 100644 index 0000000000..a3d505207a --- /dev/null +++ b/redisinsight/api/src/modules/browser/controllers/stream/stream.controller.ts @@ -0,0 +1,101 @@ +import { + Body, + Controller, + Delete, + Param, + Post, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiRedisInstanceOperation } from 'src/decorators/api-redis-instance-operation.decorator'; +import { + AddStreamEntriesDto, AddStreamEntriesResponse, + CreateStreamDto, + GetStreamEntriesDto, + GetStreamEntriesResponse, + DeleteStreamEntriesDto, + DeleteStreamEntriesResponse, +} from 'src/modules/browser/dto/stream.dto'; +import { StreamService } from 'src/modules/browser/services/stream/stream.service'; + +@ApiTags('Streams') +@Controller('streams') +@UsePipes(new ValidationPipe({ transform: true })) +export class StreamController { + constructor(private service: StreamService) {} + + @Post('') + @ApiRedisInstanceOperation({ + description: 'Create stream', + statusCode: 201, + }) + async createStream( + @Param('dbInstance') instanceId: string, + @Body() dto: CreateStreamDto, + ): Promise { + return this.service.createStream({ instanceId }, dto); + } + + @Post('entries') + @ApiRedisInstanceOperation({ + description: 'Add entries to the stream', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Returns entries IDs added', + type: AddStreamEntriesResponse, + }, + ], + }) + async addEntries( + @Param('dbInstance') instanceId: string, + @Body() dto: AddStreamEntriesDto, + ): Promise { + return this.service.addEntries({ instanceId }, dto); + } + + @Post('/entries/get') + @ApiRedisInstanceOperation({ + description: 'Get stream entries', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Returns ordered stream entries in defined range.', + type: GetStreamEntriesResponse, + }, + ], + }) + async getEntries( + @Param('dbInstance') instanceId: string, + @Body() dto: GetStreamEntriesDto, + ): Promise { + return this.service.getEntries({ instanceId }, dto); + } + + @Delete('/entries') + @ApiRedisInstanceOperation({ + description: 'Remove the specified entries from the Stream stored at key', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Ok', + type: DeleteStreamEntriesResponse, + }, + ], + }) + async deleteEntries( + @Param('dbInstance') dbInstance: string, + @Body() dto: DeleteStreamEntriesDto, + ): Promise { + return await this.service.deleteEntries( + { + instanceId: dbInstance, + }, + dto, + ); + } +} diff --git a/redisinsight/api/src/modules/browser/dto/stream.dto.ts b/redisinsight/api/src/modules/browser/dto/stream.dto.ts new file mode 100644 index 0000000000..def52f4187 --- /dev/null +++ b/redisinsight/api/src/modules/browser/dto/stream.dto.ts @@ -0,0 +1,185 @@ +import { ApiProperty, ApiPropertyOptional, IntersectionType } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + IsArray, + IsDefined, + IsEnum, IsInt, IsNotEmpty, IsString, Min, ValidateNested, isString, +} from 'class-validator'; +import { KeyDto, KeyWithExpireDto } from 'src/modules/browser/dto/keys.dto'; +import { SortOrder } from 'src/constants'; +import { Type } from 'class-transformer'; +import { IsObjectWithValues } from 'src/validators/isObjectWithValues.validator'; + +export class StreamEntryDto { + @ApiProperty({ + type: String, + description: 'Entry ID', + example: '*', + }) + @IsDefined() + @IsNotEmpty() + @IsString() + id: string; + + @ApiProperty({ + type: Object, + description: 'Entry fields', + example: { field1: 'value1', field2: 'value2' }, + }) + @IsDefined() + @IsNotEmpty() + @IsObjectWithValues([isString], { message: '$property must be an object with string values' }) + fields: Record; +} + +export class GetStreamEntriesDto extends KeyDto { + @ApiPropertyOptional({ + description: 'Specifying the start id', + type: String, + default: '-', + }) + @IsString() + start?: string = '-'; + + @ApiPropertyOptional({ + description: 'Specifying the end id', + type: String, + default: '+', + }) + @IsString() + end?: string = '+'; + + @ApiPropertyOptional({ + description: + 'Specifying the number of entries to return.', + type: Number, + minimum: 1, + default: 500, + }) + @IsInt() + @Min(1) + count?: number = 500; + + @ApiProperty({ + description: 'Get entries sort by IDs order.', + default: SortOrder.Desc, + enum: SortOrder, + }) + @IsEnum(SortOrder, { + message: `sortOrder must be a valid enum value. Valid values: ${Object.values( + SortOrder, + )}.`, + }) + sortOrder?: SortOrder = SortOrder.Desc; +} + +export class GetStreamEntriesResponse { + @ApiProperty({ + type: String, + description: 'Key Name', + }) + keyName: string; + + @ApiProperty({ + type: Number, + description: 'Total number of entries', + }) + total: number; + + @ApiProperty({ + type: String, + description: 'Last generated id in the stream', + }) + lastGeneratedId: string; + + @ApiProperty({ + description: 'First stream entry', + type: StreamEntryDto, + }) + firstEntry: StreamEntryDto; + + @ApiProperty({ + description: 'Last stream entry', + type: StreamEntryDto, + }) + lastEntry: StreamEntryDto; + + @ApiProperty({ + description: 'Stream entries', + type: StreamEntryDto, + isArray: true, + }) + entries: StreamEntryDto[]; +} + +export class AddStreamEntriesDto extends KeyDto { + @ApiProperty({ + description: 'Entries to push', + type: StreamEntryDto, + isArray: true, + example: [ + { + id: '*', + fields: { + field1: 'value1', + field2: 'value2', + }, + }, + { + id: '*', + fields: { + field1: 'value1', + field2: 'value2', + }, + }, + ], + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => StreamEntryDto) + entries: StreamEntryDto[]; +} + +export class AddStreamEntriesResponse { + @ApiProperty({ + type: String, + description: 'Key Name', + }) + keyName: string; + + @ApiProperty({ + description: 'Entries IDs', + type: String, + isArray: true, + }) + entries: string[]; +} + +export class DeleteStreamEntriesDto extends KeyDto { + @ApiProperty({ + description: 'Entries IDs', + type: String, + isArray: true, + example: ['1650985323741-0', '1650985323770-0'], + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @Type(() => String) + entries: string[]; +} + +export class DeleteStreamEntriesResponse { + @ApiProperty({ + description: 'Number of deleted entries', + type: Number, + }) + affected: number; +} + +export class CreateStreamDto extends IntersectionType( + AddStreamEntriesDto, + KeyWithExpireDto, +) {} diff --git a/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.spec.ts b/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.spec.ts index 530d08f163..1bdea789b6 100644 --- a/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.spec.ts +++ b/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import * as IORedis from 'ioredis'; import * as Redis from 'ioredis-mock'; import { mockStandaloneDatabaseEntity } from 'src/__mocks__'; import { @@ -27,7 +28,9 @@ const mockCluster = new Redis.Cluster([]); const mockClusterNode1 = new Redis(); const mockClusterNode2 = new Redis(); mockClusterNode1.send_command = jest.fn(); +mockClusterNode1.sendCommand = jest.fn(); mockClusterNode2.send_command = jest.fn(); +mockClusterNode2.sendCommand = jest.fn(); mockClusterNode1.options = { host: '127.0.0.1', port: 7001 }; mockClusterNode2.options = { host: '127.0.0.1', port: 7002 }; const mockConnectionErrorMessage = 'Could not connect to localhost, please check the connection details.'; @@ -181,7 +184,7 @@ describe('BrowserToolClusterService', () => { it('should execute command from node', async () => { getRedisClient.mockResolvedValue(mockCluster); - mockClusterNode1.send_command.mockResolvedValue(70); + mockClusterNode1.sendCommand.mockResolvedValue(70); mockCluster.nodes.mockReturnValue([mockClusterNode1, mockClusterNode2]); const result = await service.execCommandFromNode( @@ -192,10 +195,17 @@ describe('BrowserToolClusterService', () => { ); expect(result).toEqual({ result: 70, ...mockClusterNode1.options }); - expect(mockClusterNode1.send_command).toHaveBeenCalledWith('memory', [ - 'usage', - keyName, - ]); + + expect( + JSON.parse(JSON.stringify(mockClusterNode1.sendCommand.mock.calls[0])), + ).toStrictEqual(JSON.parse(JSON.stringify(([ + new IORedis.Command('memory', [ + 'usage', + keyName, + ], { + replyEncoding: 'utf8', + }), + ])))); }); it('should throw error that cluster node not found', async () => { const nodeOptions: EndpointDto = { host: '127.0.0.1', port: 7003 }; diff --git a/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.ts b/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.ts index ec0ef4b8b1..2dff29f6ee 100644 --- a/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.ts +++ b/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.ts @@ -63,7 +63,6 @@ export class BrowserToolClusterService extends RedisConsumerAbstractService { args: Array, nodeRole: NodeRole = 'all', ): Promise { - const client = await this.getRedisClient(clientOptions); const nodes: Redis[] = client.nodes(nodeRole); this.logger.log(`Execute command '${toolCommand}' from nodes, connectionName: ${getConnectionName(client)}`); @@ -91,6 +90,7 @@ export class BrowserToolClusterService extends RedisConsumerAbstractService { toolCommand: BrowserToolCommands, args: Array, exactNode: EndpointDto, + replyEncoding: string = 'utf8', ): Promise { const client = await this.getRedisClient(clientOptions); this.logger.log(`Execute command '${toolCommand}' from node, connectionName: ${getConnectionName(client)}`); @@ -112,10 +112,16 @@ export class BrowserToolClusterService extends RedisConsumerAbstractService { ), ); } - const result = await node.send_command(command, [ - ...commandArgs, - ...args, - ]); + + // @ts-ignore + // There are issues with ioredis types. Here and below + const result = await node.sendCommand( + // @ts-ignore + new IORedis.Command(command, [...commandArgs, ...args], { + replyEncoding, + }), + ); + return { host, port, diff --git a/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.spec.ts b/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.spec.ts index 229e91f4e7..40effca844 100644 --- a/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.spec.ts +++ b/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import * as IORedis from 'ioredis'; import * as Redis from 'ioredis-mock'; import { mockStandaloneDatabaseEntity } from 'src/__mocks__'; import { @@ -55,7 +56,7 @@ describe('BrowserToolService', () => { service, 'execMultiFromClient', ); - mockClient.send_command = jest.fn(); + mockClient.sendCommand = jest.fn(); }); describe('execCommand', () => { @@ -69,10 +70,16 @@ describe('BrowserToolService', () => { [keyName], ); - expect(mockClient.send_command).toHaveBeenCalledWith('memory', [ - 'usage', - keyName, - ]); + expect( + JSON.parse(JSON.stringify(mockClient.sendCommand.mock.calls[0])), + ).toStrictEqual(JSON.parse(JSON.stringify(([ + new IORedis.Command('memory', [ + 'usage', + keyName, + ], { + replyEncoding: 'utf8', + }), + ])))); }); it('should throw error for execCommand', async () => { const error = new InternalServerErrorException( @@ -87,7 +94,7 @@ describe('BrowserToolService', () => { [keyName], ), ).rejects.toThrow(InternalServerErrorException); - expect(mockClient.send_command).not.toHaveBeenCalled(); + expect(mockClient.sendCommand).not.toHaveBeenCalled(); }); }); diff --git a/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.ts b/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.ts index a661c2b32c..e52462d963 100644 --- a/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.ts +++ b/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.ts @@ -1,3 +1,4 @@ +import * as Redis from 'ioredis'; import { Injectable, Logger } from '@nestjs/common'; import { AppTool, ReplyError } from 'src/models'; import { @@ -25,12 +26,16 @@ export class BrowserToolService extends RedisConsumerAbstractService { clientOptions: IFindRedisClientInstanceByOptions, toolCommand: BrowserToolCommands, args: Array, + replyEncoding: string = 'utf8', ): Promise { const client = await this.getRedisClient(clientOptions); this.logger.log(`Execute command '${toolCommand}', connectionName: ${getConnectionName(client)}`); const [command, ...commandArgs] = toolCommand.split(' '); - // TODO: use sendCommand method - return client.send_command(command, [...commandArgs, ...args]); + return client.sendCommand( + new Redis.Command(command, [...commandArgs, ...args], { + replyEncoding, + }), + ); } async execPipeline( diff --git a/redisinsight/api/src/modules/browser/services/keys-business/keys-business.service.ts b/redisinsight/api/src/modules/browser/services/keys-business/keys-business.service.ts index 635a93d870..55b3bdc7fb 100644 --- a/redisinsight/api/src/modules/browser/services/keys-business/keys-business.service.ts +++ b/redisinsight/api/src/modules/browser/services/keys-business/keys-business.service.ts @@ -128,6 +128,11 @@ export class KeysBusinessService { ); const scanner = this.scanner.getStrategy(databaseInstance.connectionType); const result = await scanner.getKeys(clientOptions, dto); + + result.forEach((nodeResult, nodeIndex) => nodeResult.keys.forEach((key, i) => { + result[nodeIndex].keys[i].name = key.name.toString(); + })); + return result; } catch (error) { this.logger.error( diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.spec.ts index 57e3b3a72c..232c2af5d5 100644 --- a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.spec.ts +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.spec.ts @@ -104,8 +104,9 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, expect.anything(), expect.anything(), + null, ) - .mockResolvedValue({ result: [0, [getKeyInfoResponse.name]] }); + .mockResolvedValue({ result: [0, [Buffer.from(getKeyInfoResponse.name)]] }); when(browserTool.execCommandFromNode) .calledWith( mockClientOptions, @@ -168,6 +169,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', args.match, 'COUNT', args.count, 'TYPE', args.type], mockClusterNodes[0], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 5, @@ -175,6 +177,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', args.match, 'COUNT', args.count, 'TYPE', args.type], mockClusterNodes[1], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 6, @@ -182,6 +185,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', args.match, 'COUNT', args.count, 'TYPE', args.type], mockClusterNodes[2], + null, ); }); it('should call scan 3,2,1 times per nodes and return appropriate value', async () => { @@ -200,24 +204,27 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[0], + null, ) - .mockResolvedValue({ result: ['1', [getKeyInfoResponse.name]] }); + .mockResolvedValue({ result: ['1', [Buffer.from(getKeyInfoResponse.name)]] }); when(browserTool.execCommandFromNode) .calledWith( mockClientOptions, BrowserToolKeysCommands.Scan, ['1', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[0], + null, ) - .mockResolvedValue({ result: ['2', [getKeyInfoResponse.name]] }); + .mockResolvedValue({ result: ['2', [Buffer.from(getKeyInfoResponse.name)]] }); when(browserTool.execCommandFromNode) .calledWith( mockClientOptions, BrowserToolKeysCommands.Scan, ['2', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[0], + null, ) - .mockResolvedValue({ result: ['0', [getKeyInfoResponse.name]] }); + .mockResolvedValue({ result: ['0', [Buffer.from(getKeyInfoResponse.name)]] }); when(browserTool.execCommandFromNode) .calledWith( mockClientOptions, @@ -232,16 +239,18 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[1], + null, ) - .mockResolvedValue({ result: ['1', [getKeyInfoResponse.name]] }); + .mockResolvedValue({ result: ['1', [Buffer.from(getKeyInfoResponse.name)]] }); when(browserTool.execCommandFromNode) .calledWith( mockClientOptions, BrowserToolKeysCommands.Scan, ['1', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[1], + null, ) - .mockResolvedValue({ result: ['0', [getKeyInfoResponse.name]] }); + .mockResolvedValue({ result: ['0', [Buffer.from(getKeyInfoResponse.name)]] }); when(browserTool.execCommandFromNode) .calledWith( mockClientOptions, @@ -256,8 +265,9 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, expect.anything(), mockClusterNodes[2], + null, ) - .mockResolvedValue({ result: ['0', [getKeyInfoResponse.name]] }); + .mockResolvedValue({ result: ['0', [Buffer.from(getKeyInfoResponse.name)]] }); strategy.getKeysInfo = mockGetKeysInfoFn; @@ -312,6 +322,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[0], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 5, @@ -319,6 +330,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[1], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 6, @@ -326,6 +338,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[2], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 7, @@ -333,6 +346,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['1', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[0], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 8, @@ -340,6 +354,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['1', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[1], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 9, @@ -347,6 +362,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['2', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[0], + null, ); }); it('should call scan 3,2,N times per nodes until threshold exceeds', async () => { @@ -365,24 +381,27 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[0], + null, ) - .mockResolvedValue({ result: ['1', [getKeyInfoResponse.name]] }); + .mockResolvedValue({ result: ['1', [Buffer.from(getKeyInfoResponse.name)]] }); when(browserTool.execCommandFromNode) .calledWith( mockClientOptions, BrowserToolKeysCommands.Scan, ['1', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[0], + null, ) - .mockResolvedValue({ result: ['2', [getKeyInfoResponse.name]] }); + .mockResolvedValue({ result: ['2', [Buffer.from(getKeyInfoResponse.name)]] }); when(browserTool.execCommandFromNode) .calledWith( mockClientOptions, BrowserToolKeysCommands.Scan, ['2', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[0], + null, ) - .mockResolvedValue({ result: ['0', [getKeyInfoResponse.name]] }); + .mockResolvedValue({ result: ['0', [Buffer.from(getKeyInfoResponse.name)]] }); when(browserTool.execCommandFromNode) .calledWith( mockClientOptions, @@ -397,16 +416,18 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[1], + null, ) - .mockResolvedValue({ result: ['1', [getKeyInfoResponse.name]] }); + .mockResolvedValue({ result: ['1', [Buffer.from(getKeyInfoResponse.name)]] }); when(browserTool.execCommandFromNode) .calledWith( mockClientOptions, BrowserToolKeysCommands.Scan, ['1', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[1], + null, ) - .mockResolvedValue({ result: ['0', [getKeyInfoResponse.name]] }); + .mockResolvedValue({ result: ['0', [Buffer.from(getKeyInfoResponse.name)]] }); when(browserTool.execCommandFromNode) .calledWith( mockClientOptions, @@ -421,6 +442,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, expect.anything(), mockClusterNodes[2], + null, ) .mockResolvedValue({ result: ['1', []] }); @@ -483,6 +505,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[0], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 5, @@ -490,6 +513,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[1], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 6, @@ -497,6 +521,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['0', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[2], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 7, @@ -504,6 +529,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['1', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[0], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 8, @@ -511,6 +537,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['1', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[1], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 9, @@ -518,6 +545,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['1', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[2], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 10, @@ -525,6 +553,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['2', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[0], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 11, @@ -532,6 +561,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['1', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[2], + null, ); expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( 12, @@ -539,6 +569,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, ['1', 'MATCH', '*', 'COUNT', args.count], mockClusterNodes[2], + null, ); }); it('should not call scan when total is 0', async () => { @@ -758,6 +789,7 @@ describe('Cluster Scanner Strategy', () => { BrowserToolKeysCommands.Scan, expect.anything(), expect.anything(), + null, ) .mockRejectedValue(replyError); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.ts index ca4c8ae872..0b146581d8 100644 --- a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.ts +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.ts @@ -167,6 +167,7 @@ export class ClusterStrategy extends AbstractStrategy { BrowserToolKeysCommands.Scan, commandArgs, { host: node.host, port: node.port }, + null, ); // eslint-disable-next-line no-param-reassign diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.spec.ts index e36459b083..e007aa32d7 100644 --- a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.spec.ts +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.spec.ts @@ -71,6 +71,7 @@ describe('Standalone Scanner Strategy', () => { mockClientOptions, BrowserToolKeysCommands.Scan, expect.anything(), + null, ) .mockResolvedValue([0, [getKeyInfoResponse.name]]); when(browserTool.execCommand) @@ -99,6 +100,7 @@ describe('Standalone Scanner Strategy', () => { mockClientOptions, BrowserToolKeysCommands.Scan, ['0', 'MATCH', args.match, 'COUNT', args.count, 'TYPE', args.type], + null, ); }); it('should call scan 3 times and return appropriate value', async () => { @@ -109,7 +111,7 @@ describe('Standalone Scanner Strategy', () => { '*', 'COUNT', getKeysDto.count, - ]) + ], null) .mockResolvedValue(['1', new Array(3).fill(getKeyInfoResponse.name)]); when(browserTool.execCommand) .calledWith(mockClientOptions, BrowserToolKeysCommands.Scan, [ @@ -118,7 +120,7 @@ describe('Standalone Scanner Strategy', () => { '*', 'COUNT', getKeysDto.count, - ]) + ], null) .mockResolvedValue(['2', new Array(3).fill(getKeyInfoResponse.name)]); when(browserTool.execCommand) .calledWith(mockClientOptions, BrowserToolKeysCommands.Scan, [ @@ -127,7 +129,7 @@ describe('Standalone Scanner Strategy', () => { '*', 'COUNT', getKeysDto.count, - ]) + ], null) .mockResolvedValue(['0', new Array(3).fill(getKeyInfoResponse.name)]); when(browserTool.execCommand) .calledWith( @@ -164,18 +166,21 @@ describe('Standalone Scanner Strategy', () => { mockClientOptions, BrowserToolKeysCommands.Scan, ['0', 'MATCH', '*', 'COUNT', getKeysDto.count], + null, ); expect(browserTool.execCommand).toHaveBeenNthCalledWith( 3, mockClientOptions, BrowserToolKeysCommands.Scan, ['1', 'MATCH', '*', 'COUNT', getKeysDto.count], + null, ); expect(browserTool.execCommand).toHaveBeenNthCalledWith( 4, mockClientOptions, BrowserToolKeysCommands.Scan, ['2', 'MATCH', '*', 'COUNT', getKeysDto.count], + null, ); }); it('should call scan N times until threshold exceeds', async () => { @@ -184,6 +189,7 @@ describe('Standalone Scanner Strategy', () => { mockClientOptions, BrowserToolKeysCommands.Scan, expect.anything(), + null, ) .mockResolvedValue(['1', []]); when(browserTool.execCommand) @@ -304,6 +310,7 @@ describe('Standalone Scanner Strategy', () => { mockClientOptions, BrowserToolKeysCommands.Scan, expect.anything(), + null, ) .mockRejectedValue(replyError); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.ts index 6b280281f6..3ef8180dab 100644 --- a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.ts +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.ts @@ -96,6 +96,7 @@ export class StandaloneStrategy extends AbstractStrategy { clientOptions, BrowserToolKeysCommands.Scan, [...commandArgs], + null, ); const [nextCursor, keys] = execResult; diff --git a/redisinsight/api/src/modules/browser/services/stream/stream.service.spec.ts b/redisinsight/api/src/modules/browser/services/stream/stream.service.spec.ts new file mode 100644 index 0000000000..2bea789e7a --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/stream/stream.service.spec.ts @@ -0,0 +1,438 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { mockRedisConsumer, mockStandaloneDatabaseEntity, MockType } from 'src/__mocks__'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { BrowserToolKeysCommands, BrowserToolStreamCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { StreamService } from 'src/modules/browser/services/stream/stream.service'; +import { AddStreamEntriesDto, GetStreamEntriesDto, StreamEntryDto } from 'src/modules/browser/dto/stream.dto'; +import { + BadRequestException, ConflictException, InternalServerErrorException, NotFoundException, +} from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { RedisErrorCodes, SortOrder } from 'src/constants'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const mockStreamEntry: StreamEntryDto = { + id: '*', + fields: { + field1: 'value1', + }, +}; +const mockAddStreamEntriesDto: AddStreamEntriesDto = { + keyName: 'testList', + entries: [mockStreamEntry], +}; +const mockStreamInfoReply = [ + 'length', + 2, + 'radix-tree-keys', + 1, + 'radix-tree-nodes', + 2, + 'last-generated-id', + '1651130346487-1', + 'groups', + 0, + 'first-entry', + ['1651130346487-0', ['field1', 'value1', 'field2', 'value2']], + 'last-entry', + ['1651130346487-1', ['field1', 'value1', 'field2', 'value2']], +]; + +const mockEmptyStreamInfoReply = [ + 'length', + 0, + 'radix-tree-keys', + 1, + 'radix-tree-nodes', + 2, + 'last-generated-id', + '1651130346487-1', + 'groups', + 0, + 'first-entry', + null, + 'last-entry', + null, +]; + +const mockEmptyStreamInfo = { + keyName: mockAddStreamEntriesDto.keyName, + total: 0, + lastGeneratedId: '1651130346487-1', + firstEntry: null, + lastEntry: null, +}; + +const mockStreamInfo = { + keyName: mockAddStreamEntriesDto.keyName, + total: 2, + lastGeneratedId: '1651130346487-1', + firstEntry: { + id: '1651130346487-0', + fields: { field1: 'value1', field2: 'value2' }, + }, + lastEntry: { + id: '1651130346487-1', + fields: { field1: 'value1', field2: 'value2' }, + }, +}; +const mockStreamEntriesReply = [ + ['1651130346487-1', ['field1', 'value1', 'field2', 'value2']], + ['1651130346487-0', ['field1', 'value1', 'field2', 'value2']], +]; +const mockEmptyStreamEntriesReply = []; +const mockStreamEntries = [ + { id: '1651130346487-1', fields: { field1: 'value1', field2: 'value2' } }, + { id: '1651130346487-0', fields: { field1: 'value1', field2: 'value2' } }, +]; + +const mockGetStreamEntriesDto: GetStreamEntriesDto = { + keyName: mockAddStreamEntriesDto.keyName, + start: '-', + end: '+', + sortOrder: SortOrder.Desc, +}; + +describe('StreamService', () => { + let service: StreamService; + let browserTool: MockType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StreamService, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + service = module.get(StreamService); + browserTool = module.get(BrowserToolService); + }); + + describe('createStream', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [mockAddStreamEntriesDto.keyName]) + .mockResolvedValue(false); + browserTool.execMulti.mockResolvedValue([null, [[null, '123-1']]]); + }); + it('create stream with expiration', async () => { + await expect( + service.createStream(mockClientOptions, { + ...mockAddStreamEntriesDto, + expire: 1000, + }), + ).resolves.not.toThrow(); + expect(browserTool.execMulti).toHaveBeenCalledWith(mockClientOptions, [ + [BrowserToolStreamCommands.XAdd, mockAddStreamEntriesDto.keyName, mockStreamEntry.id, + ...Object.keys(mockStreamEntry.fields), ...Object.values(mockStreamEntry.fields)], + [BrowserToolKeysCommands.Expire, mockAddStreamEntriesDto.keyName, 1000], + ]); + }); + it('create stream without expiration', async () => { + await expect( + service.createStream(mockClientOptions, { + ...mockAddStreamEntriesDto, + }), + ).resolves.not.toThrow(); + expect(browserTool.execMulti).toHaveBeenCalledWith(mockClientOptions, [ + [BrowserToolStreamCommands.XAdd, mockAddStreamEntriesDto.keyName, mockStreamEntry.id, + ...Object.keys(mockStreamEntry.fields), ...Object.values(mockStreamEntry.fields)], + ]); + }); + it('should throw error key exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [mockAddStreamEntriesDto.keyName]) + .mockResolvedValueOnce(true); + + try { + await service.createStream(mockClientOptions, { + ...mockAddStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ConflictException); + expect(e.message).toEqual(ERROR_MESSAGES.KEY_NAME_EXIST); + } + }); + it('should throw Not Found error', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [mockAddStreamEntriesDto.keyName]) + .mockRejectedValueOnce(new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID)); + + try { + await service.createStream(mockClientOptions, { + ...mockAddStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID); + } + }); + it('should throw Wrong Type error', async () => { + browserTool.execMulti.mockResolvedValue([new Error(RedisErrorCodes.WrongType), [[null, '123-1']]]); + + try { + await service.createStream(mockClientOptions, { + ...mockAddStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual(RedisErrorCodes.WrongType); + } + }); + it('should throw Bad Request when incorrect ID', async () => { + browserTool.execMulti.mockResolvedValue([ + new Error('ID specified in XADD is equal or smaller'), + [[null, '123-1']], + ]); + + try { + await service.createStream(mockClientOptions, { + ...mockAddStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual('ID specified in XADD is equal or smaller'); + } + }); + it('should throw Internal Server error', async () => { + browserTool.execMulti.mockResolvedValue([ + new Error('oO'), + [[null, '123-1']], + ]); + + try { + await service.createStream(mockClientOptions, { + ...mockAddStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('oO'); + } + }); + }); + describe('addEntries', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [mockAddStreamEntriesDto.keyName]) + .mockResolvedValue(true); + browserTool.execMulti.mockResolvedValue([null, [[null, '123-1']]]); + }); + it('add entries', async () => { + await expect( + service.addEntries(mockClientOptions, { + ...mockAddStreamEntriesDto, + }), + ).resolves.not.toThrow(); + expect(browserTool.execMulti).toHaveBeenCalledWith(mockClientOptions, [ + [BrowserToolStreamCommands.XAdd, mockAddStreamEntriesDto.keyName, mockStreamEntry.id, + ...Object.keys(mockStreamEntry.fields), ...Object.values(mockStreamEntry.fields)], + ]); + }); + it('should throw Not Found when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [mockAddStreamEntriesDto.keyName]) + .mockResolvedValueOnce(false); + + try { + await service.addEntries(mockClientOptions, { + ...mockAddStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.KEY_NOT_EXIST); + } + }); + it('should throw Not Found error', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [mockAddStreamEntriesDto.keyName]) + .mockRejectedValueOnce(new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID)); + + try { + await service.addEntries(mockClientOptions, { + ...mockAddStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID); + } + }); + it('should throw Wrong Type error', async () => { + browserTool.execMulti.mockResolvedValue([new Error(RedisErrorCodes.WrongType), [[null, '123-1']]]); + + try { + await service.addEntries(mockClientOptions, { + ...mockAddStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual(RedisErrorCodes.WrongType); + } + }); + it('should throw Bad Request when incorrect ID', async () => { + browserTool.execMulti.mockResolvedValue([ + new Error('ID specified in XADD is equal or smaller'), + [[null, '123-1']], + ]); + + try { + await service.addEntries(mockClientOptions, { + ...mockAddStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual('ID specified in XADD is equal or smaller'); + } + }); + it('should throw Internal Server error', async () => { + browserTool.execMulti.mockResolvedValue([ + new Error('oO'), + [[null, '123-1']], + ]); + + try { + await service.addEntries(mockClientOptions, { + ...mockAddStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('oO'); + } + }); + }); + describe('get etries from empty stream', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [mockAddStreamEntriesDto.keyName]) + .mockResolvedValue(true); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XInfoStream, [mockAddStreamEntriesDto.keyName]) + .mockResolvedValue(mockEmptyStreamInfoReply); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XRevRange, expect.anything()) + .mockResolvedValue(mockEmptyStreamEntriesReply); + }); + it('Should return stream with 0 entries', async () => { + const result = await service.getEntries(mockClientOptions, { + ...mockGetStreamEntriesDto, + }); + expect(result).toEqual({ + ...mockEmptyStreamInfo, + entries: mockEmptyStreamEntriesReply, + }); + }); + }); + describe('getEntries', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [mockAddStreamEntriesDto.keyName]) + .mockResolvedValue(true); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XInfoStream, [mockAddStreamEntriesDto.keyName]) + .mockResolvedValue(mockStreamInfoReply); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XRevRange, expect.anything()) + .mockResolvedValue(mockStreamEntriesReply); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XRange, expect.anything()) + .mockResolvedValue(mockStreamEntriesReply); + }); + it('get entries DESC', async () => { + const result = await service.getEntries(mockClientOptions, { + ...mockGetStreamEntriesDto, + }); + expect(result).toEqual({ + ...mockStreamInfo, + entries: mockStreamEntries, + }); + }); + it('get entries ASC', async () => { + const result = await service.getEntries(mockClientOptions, { + ...mockGetStreamEntriesDto, + sortOrder: SortOrder.Asc, + }); + expect(result).toEqual({ + ...mockStreamInfo, + entries: mockStreamEntries, + }); + }); + it('should throw Not Found when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [mockAddStreamEntriesDto.keyName]) + .mockResolvedValueOnce(false); + + try { + await service.getEntries(mockClientOptions, { + ...mockGetStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.KEY_NOT_EXIST); + } + }); + it('should throw Not Found error', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [mockAddStreamEntriesDto.keyName]) + .mockRejectedValueOnce(new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID)); + + try { + await service.getEntries(mockClientOptions, { + ...mockGetStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID); + } + }); + it('should throw Wrong Type error', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XInfoStream, [mockAddStreamEntriesDto.keyName]) + .mockRejectedValueOnce(new Error(RedisErrorCodes.WrongType)); + + try { + await service.getEntries(mockClientOptions, { + ...mockAddStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual(RedisErrorCodes.WrongType); + } + }); + it('should throw Internal Server error', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XInfoStream, [mockAddStreamEntriesDto.keyName]) + .mockRejectedValueOnce(new Error('oO')); + + try { + await service.getEntries(mockClientOptions, { + ...mockGetStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('oO'); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/stream/stream.service.ts b/redisinsight/api/src/modules/browser/services/stream/stream.service.ts new file mode 100644 index 0000000000..41016cfdec --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/stream/stream.service.ts @@ -0,0 +1,371 @@ +import { chunk, flatMap, map } from 'lodash'; +import { + BadRequestException, ConflictException, + Injectable, + Logger, NotFoundException, +} from '@nestjs/common'; +import { catchAclError, catchTransactionError, convertStringsArrayToObject } from 'src/utils'; +import { SortOrder } from 'src/constants/sort'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { + BrowserToolCommands, + BrowserToolKeysCommands, + BrowserToolStreamCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { + AddStreamEntriesDto, AddStreamEntriesResponse, + CreateStreamDto, + GetStreamEntriesDto, + GetStreamEntriesResponse, + DeleteStreamEntriesDto, + DeleteStreamEntriesResponse, + StreamEntryDto, +} from 'src/modules/browser/dto/stream.dto'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { RedisErrorCodes } from 'src/constants'; + +@Injectable() +export class StreamService { + private logger = new Logger('StreamService'); + + constructor(private browserTool: BrowserToolService) {} + + /** + * Get stream entries + * Could be used for lazy loading with "start", "end" and "count" parameters + * Could be sorted using "sortOrder" in ASC and DESC order + * + * @param clientOptions + * @param dto + */ + public async getEntries( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetStreamEntriesDto, + ): Promise { + try { + this.logger.log('Getting entries of the Stream data type stored at key.'); + + const { keyName, sortOrder } = dto; + + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + + if (!exists) { + throw new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST); + } + + const info = convertStringsArrayToObject(await this.browserTool.execCommand( + clientOptions, + BrowserToolStreamCommands.XInfoStream, + [keyName], + )); + + let entries = []; + if (sortOrder && sortOrder === SortOrder.Asc) { + entries = await this.getRange(clientOptions, dto); + } else { + entries = await this.getRevRange(clientOptions, dto); + } + + this.logger.log('Succeed to get entries from the stream.'); + + return { + keyName, + total: info.length, + lastGeneratedId: info['last-generated-id'], + firstEntry: StreamService.formatArrayToDto(info['first-entry']), + lastEntry: StreamService.formatArrayToDto(info['last-entry']), + entries, + }; + } catch (error) { + this.logger.error('Failed to get entries from the stream.', error); + + if (error instanceof NotFoundException) { + throw error; + } + + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + + throw catchAclError(error); + } + } + + /** + * Return specified number of entries in the time range in ASC order + * + * @param clientOptions + * @param dto + */ + public async getRange( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetStreamEntriesDto, + ): Promise { + const { + keyName, start, end, count, + } = dto; + + const execResult = await this.browserTool.execCommand( + clientOptions, + BrowserToolStreamCommands.XRange, + [keyName, start, end, 'COUNT', count], + ); + + return StreamService.formatReplyToDto(execResult); + } + + /** + * Return specified number of entries in the time range in DESC order + * + * @param clientOptions + * @param dto + */ + public async getRevRange( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetStreamEntriesDto, + ): Promise { + const { + keyName, start, end, count, + } = dto; + + const execResult = await this.browserTool.execCommand( + clientOptions, + BrowserToolStreamCommands.XRevRange, + [keyName, end, start, 'COUNT', count], + ); + + return StreamService.formatReplyToDto(execResult); + } + + /** + * Create streams with\without expiration time and add multiple entries in a transaction + * @param clientOptions + * @param dto + */ + public async createStream( + clientOptions: IFindRedisClientInstanceByOptions, + dto: CreateStreamDto, + ): Promise { + this.logger.log('Creating stream data type.'); + + try { + const { keyName, entries } = dto; + + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (isExist) { + this.logger.error( + `Failed to create stream data type. ${ERROR_MESSAGES.KEY_NAME_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new ConflictException(ERROR_MESSAGES.KEY_NAME_EXIST), + ); + } + + const entriesArray = entries.map((entry) => [ + entry.id, + ...flatMap(map(entry.fields, (value, field) => [field, value])), + ]); + + const toolCommands: Array<[ + toolCommand: BrowserToolCommands, + ...args: Array, + ]> = entriesArray.map((entry) => ( + [ + BrowserToolStreamCommands.XAdd, + keyName, + ...entry, + ] + )); + + if (dto.expire) { + toolCommands.push([BrowserToolKeysCommands.Expire, keyName, dto.expire]); + } + + const [ + transactionError, + transactionResults, + ] = await this.browserTool.execMulti(clientOptions, toolCommands); + catchTransactionError(transactionError, transactionResults); + + this.logger.log('Succeed to create stream.'); + + return undefined; + } catch (error) { + this.logger.error('Failed to create stream.', error); + + if (error instanceof NotFoundException) { + throw error; + } + + if ( + error?.message.includes(RedisErrorCodes.WrongType) + || error?.message.includes('ID specified in XADD is equal or smaller') + ) { + throw new BadRequestException(error.message); + } + + throw catchAclError(error); + } + } + + /** + * Add entries to the existing stream and return entries IDs list + * @param clientOptions + * @param dto + */ + public async addEntries( + clientOptions: IFindRedisClientInstanceByOptions, + dto: AddStreamEntriesDto, + ): Promise { + this.logger.log('Adding entries to stream.'); + + try { + const { keyName, entries } = dto; + + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + + if (!exists) { + throw new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST); + } + + const entriesArray = entries.map((entry) => [ + entry.id, + ...flatMap(map(entry.fields, (value, field) => [field, value])), + ]); + + const toolCommands: Array<[ + toolCommand: BrowserToolCommands, + ...args: Array, + ]> = entriesArray.map((entry) => ( + [ + BrowserToolStreamCommands.XAdd, + keyName, + ...entry, + ] + )); + + const [ + transactionError, + transactionResults, + ] = await this.browserTool.execMulti(clientOptions, toolCommands); + catchTransactionError(transactionError, transactionResults); + + this.logger.log('Succeed to add entries to the stream.'); + + return { + keyName, + entries: transactionResults.map((entryResult) => entryResult[1]), + }; + } catch (error) { + this.logger.error('Failed to add entries to the stream.', error); + + if (error instanceof NotFoundException) { + throw error; + } + + if ( + error?.message.includes(RedisErrorCodes.WrongType) + || error?.message.includes('ID specified in XADD is equal or smaller') + ) { + throw new BadRequestException(error.message); + } + + throw catchAclError(error); + } + } + + /** + * Delete entries from the existing stream and return number of deleted entries + * @param clientOptions + * @param dto + */ + public async deleteEntries( + clientOptions: IFindRedisClientInstanceByOptions, + dto: DeleteStreamEntriesDto, + ): Promise { + this.logger.log('Deleting entries from the Stream data type.'); + const { keyName, entries } = dto; + let result; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (!isExist) { + this.logger.error( + `Failed to delete entries from the Stream data type. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + result = await this.browserTool.execCommand( + clientOptions, + BrowserToolStreamCommands.XDel, + [keyName, ...entries], + ); + } catch (error) { + this.logger.error('Failed to delete entries from the Stream data type.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + catchAclError(error); + } + this.logger.log('Succeed to delete entries from the Stream data type.'); + return { affected: result }; + } + + /** + * Converts RESP response from Redis + * [ + * [ '1650985323741-0', [ 'field', 'value' ] ], + * [ '1650985351882-0', [ 'field', 'value2' ] ], + * ... + * ] + * + * to DTO + * + * [ + * { id: '1650985323741-0', fields: { field: 'value' } }, + * { id: '1650985351882-0', fields: { field: 'value2' } }, + * ... + * ] + * @param reply + */ + static formatReplyToDto(reply: Array): StreamEntryDto[] { + return reply.map(StreamService.formatArrayToDto); + } + + /** + * Format single reply entry to DTO + * @param entry + */ + static formatArrayToDto(entry: Array): StreamEntryDto { + if (!entry?.length) { + return null; + } + + const dto = { id: entry[0], fields: {} }; + + chunk(entry[1] || [], 2).forEach((keyFieldPair) => { + // eslint-disable-next-line prefer-destructuring + dto.fields[keyFieldPair[0]] = keyFieldPair[1]; + }); + + return dto; + } +} diff --git a/redisinsight/api/src/modules/instances/dto/database-instance.dto.ts b/redisinsight/api/src/modules/instances/dto/database-instance.dto.ts index 4ecc9333bc..074d78eb2b 100644 --- a/redisinsight/api/src/modules/instances/dto/database-instance.dto.ts +++ b/redisinsight/api/src/modules/instances/dto/database-instance.dto.ts @@ -212,7 +212,6 @@ export class ConnectionOptionsDto extends EndpointDto { example: 0, }) @IsInt() - @Max(15) @Min(0) @Type(() => Number) @IsOptional() @@ -403,15 +402,13 @@ export class AddDatabaseInstanceDto extends ConnectionOptionsDto { export class ConnectToRedisDatabaseIndexDto { @ApiPropertyOptional({ - description: 'Databases index. Redis databases are numbered from 0 to 15.', + description: 'Databases index.', type: Number, minimum: 0, - maximum: 15, default: 0, }) @IsInt() @Min(0) - @Max(15) @Type(() => Number) @IsNotEmpty() dbNumber?: number; diff --git a/redisinsight/api/src/modules/instances/dto/redis-sentinel.dto.ts b/redisinsight/api/src/modules/instances/dto/redis-sentinel.dto.ts index 9024091e4e..a15aec686c 100644 --- a/redisinsight/api/src/modules/instances/dto/redis-sentinel.dto.ts +++ b/redisinsight/api/src/modules/instances/dto/redis-sentinel.dto.ts @@ -64,7 +64,6 @@ export class AddSentinelMasterDto { example: 0, }) @IsInt() - @Max(15) @Min(0) @Type(() => Number) @IsOptional() diff --git a/redisinsight/api/src/modules/slow-log/constants/commands.ts b/redisinsight/api/src/modules/slow-log/constants/commands.ts new file mode 100644 index 0000000000..896be73017 --- /dev/null +++ b/redisinsight/api/src/modules/slow-log/constants/commands.ts @@ -0,0 +1,10 @@ +export enum SlowLogCommands { + SlowLog = 'slowlog', + Config = 'config', +} + +export enum SlowLogArguments { + Get = 'get', + Set = 'set', + Reset = 'reset', +} diff --git a/redisinsight/api/src/modules/slow-log/dto/get-slow-logs.dto.ts b/redisinsight/api/src/modules/slow-log/dto/get-slow-logs.dto.ts new file mode 100644 index 0000000000..4ac14406af --- /dev/null +++ b/redisinsight/api/src/modules/slow-log/dto/get-slow-logs.dto.ts @@ -0,0 +1,20 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsInt, IsNotEmpty, IsOptional, Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class GetSlowLogsDto { + @ApiPropertyOptional({ + description: 'Specifying the number of slow logs to fetch per node.', + type: Number, + minimum: -1, + default: 50, + }) + @IsInt() + @Min(-1) + @Type(() => Number) + @IsNotEmpty() + @IsOptional() + count?: number = 50; +} diff --git a/redisinsight/api/src/modules/slow-log/dto/update-slow-log-config.dto.ts b/redisinsight/api/src/modules/slow-log/dto/update-slow-log-config.dto.ts new file mode 100644 index 0000000000..6bd4948129 --- /dev/null +++ b/redisinsight/api/src/modules/slow-log/dto/update-slow-log-config.dto.ts @@ -0,0 +1,3 @@ +import { SlowLogConfig } from 'src/modules/slow-log/models'; + +export class UpdateSlowLogConfigDto extends SlowLogConfig {} diff --git a/redisinsight/api/src/modules/slow-log/models/index.ts b/redisinsight/api/src/modules/slow-log/models/index.ts new file mode 100644 index 0000000000..7141acbab2 --- /dev/null +++ b/redisinsight/api/src/modules/slow-log/models/index.ts @@ -0,0 +1,2 @@ +export * from './slow-log'; +export * from './slow-log-config'; diff --git a/redisinsight/api/src/modules/slow-log/models/slow-log-config.ts b/redisinsight/api/src/modules/slow-log/models/slow-log-config.ts new file mode 100644 index 0000000000..daf194ca8e --- /dev/null +++ b/redisinsight/api/src/modules/slow-log/models/slow-log-config.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsInt, + IsNumber, Min, NotEquals, ValidateIf, +} from 'class-validator'; + +export class SlowLogConfig { + @ApiPropertyOptional({ + description: 'Max logs to store inside Redis slowlog', + example: 128, + type: Number, + }) + @NotEquals(null) + @ValidateIf((object, value) => value !== undefined) + @IsInt() + @Min(0) + slowlogMaxLen?: number; + + @ApiPropertyOptional({ + description: 'Store logs with execution time greater than this value (in microseconds)', + example: 10000, + type: Number, + }) + @NotEquals(null) + @ValidateIf((object, value) => value !== undefined) + @IsInt() + @Min(-1) + slowlogLogSlowerThan?: number; +} diff --git a/redisinsight/api/src/modules/slow-log/models/slow-log.ts b/redisinsight/api/src/modules/slow-log/models/slow-log.ts new file mode 100644 index 0000000000..7058faa05e --- /dev/null +++ b/redisinsight/api/src/modules/slow-log/models/slow-log.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class SlowLog { + @ApiProperty({ + description: 'Unique slowlog Id calculated by Redis', + example: 12, + type: Number, + }) + id: number; + + @ApiProperty({ + description: 'Time when command was executed', + example: 1652265051, + type: Number, + }) + time: number; + + @ApiProperty({ + description: 'Time needed to execute this command in microseconds', + example: 57000, + type: Number, + }) + durationUs: number; + + @ApiProperty({ + description: 'Command with args', + example: 'SET foo bar', + type: String, + }) + args: string; + + @ApiProperty({ + description: 'Client that executed this command', + example: '127.17.0.1:46922', + type: String, + }) + source: string; + + @ApiPropertyOptional({ + description: 'Client name if defined', + example: 'redisinsight-common-e25b587e', + type: String, + }) + client?: string; +} diff --git a/redisinsight/api/src/modules/slow-log/slow-log-analytics.service.ts b/redisinsight/api/src/modules/slow-log/slow-log-analytics.service.ts new file mode 100644 index 0000000000..0923c1be6a --- /dev/null +++ b/redisinsight/api/src/modules/slow-log/slow-log-analytics.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { TelemetryBaseService } from 'src/modules/shared/services/base/telemetry.base.service'; + +@Injectable() +export class SlowLogAnalyticsService extends TelemetryBaseService { + private events: Map = new Map(); + + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + this.events.set(TelemetryEvents.SlowlogSetLogSlowerThan, this.slowlogLogSlowerThanUpdated.bind(this)); + this.events.set(TelemetryEvents.SlowlogSetMaxLen, this.slowlogMaxLenUpdated.bind(this)); + } + + updateSlowlogConfig(event: TelemetryEvents, eventData: any): void { + try { + this.sendEvent(event, eventData); + } catch (e) { + // continue regardless of error + } + } + + slowlogMaxLenUpdated(databaseId: string, previousValue: number, currentValue: number): void { + this.updateSlowlogConfig( + TelemetryEvents.SlowlogSetMaxLen, + { + databaseId, + previousValue, + currentValue, + }, + ); + } + + slowlogLogSlowerThanUpdated(databaseId: string, previousValue: number, currentValue: number): void { + this.updateSlowlogConfig( + TelemetryEvents.SlowlogSetLogSlowerThan, + { + databaseId, + previousValue, + currentValue, + }, + ); + } +} diff --git a/redisinsight/api/src/modules/slow-log/slow-log.controller.ts b/redisinsight/api/src/modules/slow-log/slow-log.controller.ts new file mode 100644 index 0000000000..c66e15006c --- /dev/null +++ b/redisinsight/api/src/modules/slow-log/slow-log.controller.ts @@ -0,0 +1,97 @@ +import { + Body, + Controller, Delete, Get, Param, Patch, Query, UsePipes, ValidationPipe, +} from '@nestjs/common'; +import { SlowLogService } from 'src/modules/slow-log/slow-log.service'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { SlowLog, SlowLogConfig } from 'src/modules/slow-log/models'; +import { AppTool } from 'src/models'; +import { UpdateSlowLogConfigDto } from 'src/modules/slow-log/dto/update-slow-log-config.dto'; +import { GetSlowLogsDto } from 'src/modules/slow-log/dto/get-slow-logs.dto'; + +@ApiTags('Slow Logs') +@Controller('slow-logs') +@UsePipes(new ValidationPipe({ transform: true })) +export class SlowLogController { + constructor( + private service: SlowLogService, + ) {} + + @ApiEndpoint({ + description: 'List of slow logs', + statusCode: 200, + responses: [ + { + status: 200, + type: SlowLog, + isArray: true, + }, + ], + }) + @Get('') + async getSlowLogs( + @Param('dbInstance') instanceId: string, + @Query() getSlowLogsDto: GetSlowLogsDto, + ): Promise { + return this.service.getSlowLogs({ + instanceId, + tool: AppTool.Common, + }, getSlowLogsDto); + } + + @ApiEndpoint({ + description: 'Clear slow logs', + statusCode: 200, + }) + @Delete('') + async resetSlowLogs( + @Param('dbInstance') instanceId: string, + ): Promise { + return this.service.reset({ + instanceId, + tool: AppTool.Common, + }); + } + + @ApiEndpoint({ + description: 'Get slowlog config', + statusCode: 200, + responses: [ + { + status: 200, + type: SlowLogConfig, + }, + ], + }) + @Get('config') + async getConfig( + @Param('dbInstance') instanceId: string, + ): Promise { + return this.service.getConfig({ + instanceId, + tool: AppTool.Common, + }); + } + + @ApiEndpoint({ + description: 'Update slowlog config', + statusCode: 200, + responses: [ + { + status: 200, + type: SlowLogConfig, + }, + ], + }) + @Patch('config') + async updateConfig( + @Param('dbInstance') instanceId: string, + @Body() dto: UpdateSlowLogConfigDto, + ): Promise { + return this.service.updateConfig({ + instanceId, + tool: AppTool.Common, + }, dto); + } +} diff --git a/redisinsight/api/src/modules/slow-log/slow-log.module.ts b/redisinsight/api/src/modules/slow-log/slow-log.module.ts new file mode 100644 index 0000000000..18503c9dba --- /dev/null +++ b/redisinsight/api/src/modules/slow-log/slow-log.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SlowLogController } from 'src/modules/slow-log/slow-log.controller'; +import { SlowLogService } from 'src/modules/slow-log/slow-log.service'; +import { SharedModule } from 'src/modules/shared/shared.module'; +import { SlowLogAnalyticsService } from 'src/modules/slow-log/slow-log-analytics.service'; + +@Module({ + imports: [SharedModule], + providers: [SlowLogService, SlowLogAnalyticsService], + controllers: [SlowLogController], +}) +export class SlowLogModule {} diff --git a/redisinsight/api/src/modules/slow-log/slow-log.service.spec.ts b/redisinsight/api/src/modules/slow-log/slow-log.service.spec.ts new file mode 100644 index 0000000000..a1c583f416 --- /dev/null +++ b/redisinsight/api/src/modules/slow-log/slow-log.service.spec.ts @@ -0,0 +1,268 @@ +import * as Redis from 'ioredis'; +import { Test, TestingModule } from '@nestjs/testing'; +import { mockRedisNoPermError, mockStandaloneDatabaseEntity, MockType } from 'src/__mocks__'; +import { IFindRedisClientInstanceByOptions, RedisService } from 'src/modules/core/services/redis/redis.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { SlowLogService } from 'src/modules/slow-log/slow-log.service'; +import { AppTool } from 'src/models'; +import { mockRedisClientInstance } from 'src/modules/shared/services/base/redis-consumer.abstract.service.spec'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { SlowLogArguments, SlowLogCommands } from 'src/modules/slow-log/constants/commands'; +import { SlowLogAnalyticsService } from 'src/modules/slow-log/slow-log-analytics.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, + tool: AppTool.Common, +}; + +const getSlowLogDto = { count: 100 }; +const mockSlowLog = { + id: 1, + time: 165234561, + durationUs: 100, + args: 'get foo', + source: '127.0.0.1:12399', + client: 'client-name', +}; + +const mockLogReply = [ + mockSlowLog.id, + mockSlowLog.time, + mockSlowLog.durationUs, + mockSlowLog.args.split(' '), + mockSlowLog.source, + mockSlowLog.client, +]; +const mockSlowLogConfig = { + slowlogMaxLen: 128, + slowlogLogSlowerThan: 10000, +}; + +const mockSlowlogConfigReply = [ + 'slowlog-max-len', + mockSlowLogConfig.slowlogMaxLen, + 'slowlog-log-slower-than', + mockSlowLogConfig.slowlogLogSlowerThan, +]; + +const mockSlowLogReply = [mockLogReply, mockLogReply]; + +const mockRedisNode = Object.create(Redis.prototype); +mockRedisNode.send_command = jest.fn(); + +const mockRedisCluster = Object.create(Redis.Cluster.prototype); +mockRedisCluster.send_command = jest.fn(); +mockRedisCluster.nodes = jest.fn().mockResolvedValue([mockRedisNode, mockRedisNode]); + +describe('SlowLogService', () => { + let service: SlowLogService; + let analyticsService: SlowLogAnalyticsService; + let redisService: MockType; + let databaseService: MockType; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SlowLogService, + EventEmitter2, + SlowLogAnalyticsService, + { + provide: RedisService, + useFactory: () => ({ + getClientInstance: jest.fn(), + isClientConnected: jest.fn(), + }), + }, + { + provide: InstancesBusinessService, + useFactory: () => ({ + connectToInstance: jest.fn(), + getOneById: jest.fn(), + }), + }, + ], + }).compile(); + + service = await module.get(SlowLogService); + redisService = await module.get(RedisService); + databaseService = await module.get(InstancesBusinessService); + analyticsService = await module.get(SlowLogAnalyticsService); + + redisService.getClientInstance.mockReturnValue({ + ...mockRedisClientInstance, + client: mockRedisNode, + }); + redisService.isClientConnected.mockReturnValue(true); + mockRedisNode.send_command.mockResolvedValue(mockSlowLogReply); + databaseService.connectToInstance.mockResolvedValueOnce(mockRedisNode); + }); + + describe('getSlowLogs', () => { + it('should return slowlogs for standalone', async () => { + const res = await service.getSlowLogs(mockClientOptions, getSlowLogDto); + expect(res).toEqual([mockSlowLog, mockSlowLog]); + }); + it('should return slowlogs for standalone without connection', async () => { + redisService.getClientInstance.mockReturnValueOnce(false); + const res = await service.getSlowLogs(mockClientOptions, getSlowLogDto); + expect(res).toEqual([mockSlowLog, mockSlowLog]); + }); + it('should return slowlogs for standalone without active connection', async () => { + redisService.isClientConnected.mockReturnValue(false); + const res = await service.getSlowLogs(mockClientOptions, getSlowLogDto); + expect(res).toEqual([mockSlowLog, mockSlowLog]); + }); + it('should return slowlogs cluster', async () => { + redisService.getClientInstance.mockReturnValue({ + ...mockRedisClientInstance, + client: mockRedisCluster, + }); + const res = await service.getSlowLogs(mockClientOptions, getSlowLogDto); + expect(res).toEqual([mockSlowLog, mockSlowLog, mockSlowLog, mockSlowLog]); + }); + it('should proxy HttpException', async () => { + try { + redisService.getClientInstance.mockImplementationOnce(() => { throw new BadRequestException('error'); }); + await service.getSlowLogs(mockClientOptions, getSlowLogDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + } + }); + it('should throw an Forbidden error when command execution failed', async () => { + try { + redisService.getClientInstance.mockImplementationOnce(() => { throw mockRedisNoPermError; }); + await service.getSlowLogs(mockClientOptions, getSlowLogDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('reset', () => { + it('should reset slowlogs for standalone', async () => { + await service.reset(mockClientOptions); + expect(mockRedisNode.send_command).toHaveBeenCalledWith(SlowLogCommands.SlowLog, SlowLogArguments.Reset); + }); + it('should reset slowlogs cluster', async () => { + redisService.getClientInstance.mockReturnValue({ + ...mockRedisClientInstance, + client: mockRedisCluster, + }); + await service.reset(mockClientOptions); + expect(mockRedisNode.send_command).toHaveBeenCalledWith(SlowLogCommands.SlowLog, SlowLogArguments.Reset); + }); + it('should proxy HttpException', async () => { + try { + redisService.getClientInstance.mockImplementationOnce(() => { throw new BadRequestException('error'); }); + await service.reset(mockClientOptions); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + } + }); + it('should throw an Forbidden error when command execution failed', async () => { + try { + redisService.getClientInstance.mockImplementationOnce(() => { throw mockRedisNoPermError; }); + await service.reset(mockClientOptions); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('getConfig', () => { + it('should get slowlogs config', async () => { + mockRedisNode.send_command.mockResolvedValueOnce(mockSlowlogConfigReply); + + const res = await service.getConfig(mockClientOptions); + expect(res).toEqual(mockSlowLogConfig); + }); + it('should get ONLY supported slowlogs config even if there some extra fields in resp', async () => { + mockRedisNode.send_command.mockResolvedValueOnce([ + ...mockSlowlogConfigReply, + 'slowlog-extra', + 12, + ]); + + const res = await service.getConfig(mockClientOptions); + expect(res).toEqual(mockSlowLogConfig); + }); + it('should proxy HttpException', async () => { + try { + redisService.getClientInstance.mockImplementationOnce(() => { throw new BadRequestException('error'); }); + await service.getConfig(mockClientOptions); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + } + }); + it('should throw an Forbidden error when command execution failed', async () => { + try { + redisService.getClientInstance.mockImplementationOnce(() => { throw mockRedisNoPermError; }); + await service.getConfig(mockClientOptions); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('updateConfig', () => { + it('should update slowlogs config (1 field)', async () => { + mockRedisNode.send_command.mockResolvedValueOnce(mockSlowlogConfigReply); + mockRedisNode.send_command.mockResolvedValueOnce('OK'); + + const res = await service.updateConfig(mockClientOptions, { slowlogMaxLen: 128 }); + expect(res).toEqual(mockSlowLogConfig); + expect(mockRedisNode.send_command).toHaveBeenCalledTimes(2); + }); + it('should update slowlogs config (2 fields)', async () => { + mockRedisNode.send_command + .mockResolvedValueOnce(mockSlowlogConfigReply) + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OK'); + + const res = await service.updateConfig(mockClientOptions, { slowlogMaxLen: 128, slowlogLogSlowerThan: 1 }); + expect(res).toEqual({ slowlogMaxLen: 128, slowlogLogSlowerThan: 1 }); + expect(mockRedisNode.send_command).toHaveBeenCalledTimes(3); + }); + it('should throw an error for cluster', async () => { + try { + mockRedisCluster.send_command.mockResolvedValueOnce(mockSlowlogConfigReply); + + redisService.getClientInstance.mockReturnValue({ + ...mockRedisClientInstance, + client: mockRedisCluster, + }); + await service.updateConfig(mockClientOptions, { slowlogMaxLen: 128, slowlogLogSlowerThan: 1 }); + + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + } + }); + it('should proxy HttpException', async () => { + try { + redisService.getClientInstance.mockImplementationOnce(() => { throw new BadRequestException('error'); }); + await service.updateConfig(mockClientOptions, { slowlogMaxLen: 1 }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + } + }); + it('should throw an Forbidden error when command execution failed', async () => { + try { + redisService.getClientInstance.mockImplementationOnce(() => { throw mockRedisNoPermError; }); + await service.updateConfig(mockClientOptions, { slowlogMaxLen: 1 }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/slow-log/slow-log.service.ts b/redisinsight/api/src/modules/slow-log/slow-log.service.ts new file mode 100644 index 0000000000..153e506eeb --- /dev/null +++ b/redisinsight/api/src/modules/slow-log/slow-log.service.ts @@ -0,0 +1,218 @@ +import IORedis from 'ioredis'; +import { concat } from 'lodash'; +import { + BadRequestException, HttpException, Injectable, Logger, +} from '@nestjs/common'; +import { IFindRedisClientInstanceByOptions, RedisService } from 'src/modules/core/services/redis/redis.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { SlowLog, SlowLogConfig } from 'src/modules/slow-log/models'; +import { SlowLogArguments, SlowLogCommands } from 'src/modules/slow-log/constants/commands'; +import { catchAclError, convertStringsArrayToObject } from 'src/utils'; +import { UpdateSlowLogConfigDto } from 'src/modules/slow-log/dto/update-slow-log-config.dto'; +import { GetSlowLogsDto } from 'src/modules/slow-log/dto/get-slow-logs.dto'; +import { SlowLogAnalyticsService } from 'src/modules/slow-log/slow-log-analytics.service'; + +@Injectable() +export class SlowLogService { + private logger = new Logger('SlowLogService'); + + constructor( + private redisService: RedisService, + private instancesBusinessService: InstancesBusinessService, + private analyticsService: SlowLogAnalyticsService, + ) {} + + /** + * Get slow logs for each node and return concatenated result + * @param clientOptions + * @param dto + */ + async getSlowLogs( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetSlowLogsDto, + ) { + try { + this.logger.log('Getting slow logs'); + + const client = await this.getClient(clientOptions); + const nodes = await this.getNodes(client); + + return concat(...(await Promise.all(nodes.map((node) => this.getNodeSlowLogs(node, dto))))); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + + throw catchAclError(e); + } + } + + /** + * Get array of slow logs for particular node + * @param node + * @param dto + */ + async getNodeSlowLogs(node: IORedis.Redis, dto: GetSlowLogsDto): Promise { + const resp = await node.send_command(SlowLogCommands.SlowLog, [SlowLogArguments.Get, dto.count]); + return resp.map((log) => { + const [id, time, durationUs, args, source, client] = log; + + return { + id, + time, + durationUs, + args: args.join(' '), + source, + client, + }; + }); + } + + /** + * Clear slow logs in all nodes + * @param clientOptions + */ + async reset( + clientOptions: IFindRedisClientInstanceByOptions, + ): Promise { + try { + this.logger.log('Resetting slow logs'); + + const client = await this.getClient(clientOptions); + const nodes = await this.getNodes(client); + + await Promise.all(nodes.map((node) => node.send_command(SlowLogCommands.SlowLog, SlowLogArguments.Reset))); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + + throw catchAclError(e); + } + } + + /** + * Get current slowlog config to show for user + * @param clientOptions + */ + async getConfig( + clientOptions: IFindRedisClientInstanceByOptions, + ): Promise { + try { + const client = await this.getClient(clientOptions); + const resp = convertStringsArrayToObject( + await client.send_command(SlowLogCommands.Config, [SlowLogArguments.Get, 'slowlog*']), + ); + + return { + slowlogMaxLen: parseInt(resp['slowlog-max-len'], 10) || 0, + slowlogLogSlowerThan: parseInt(resp['slowlog-log-slower-than'], 10) || 0, + }; + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + + throw catchAclError(e); + } + } + + /** + * Update slowlog config + * @param clientOptions + * @param dto + */ + async updateConfig( + clientOptions: IFindRedisClientInstanceByOptions, + dto: UpdateSlowLogConfigDto, + ): Promise { + try { + const commands = []; + const config = await this.getConfig(clientOptions); + const { slowlogLogSlowerThan, slowlogMaxLen } = config; + + if (dto.slowlogLogSlowerThan !== undefined) { + commands.push({ + command: SlowLogCommands.Config, + args: [SlowLogArguments.Set, 'slowlog-log-slower-than', dto.slowlogLogSlowerThan], + analytics: () => this.analyticsService.slowlogLogSlowerThanUpdated( + clientOptions.instanceId, + slowlogLogSlowerThan, + dto.slowlogLogSlowerThan, + ), + }); + + config.slowlogLogSlowerThan = dto.slowlogLogSlowerThan; + } + + if (dto.slowlogMaxLen !== undefined) { + commands.push({ + command: SlowLogCommands.Config, + args: [SlowLogArguments.Set, 'slowlog-max-len', dto.slowlogMaxLen], + analytics: () => this.analyticsService.slowlogMaxLenUpdated( + clientOptions.instanceId, + slowlogMaxLen, + dto.slowlogMaxLen, + ), + }); + + config.slowlogMaxLen = dto.slowlogMaxLen; + } + + if (commands.length) { + const client = await this.getClient(clientOptions); + + if (client instanceof IORedis.Cluster) { + return Promise.reject(new BadRequestException('Configuration slowlog for cluster is deprecated')); + } + await Promise.all(commands.map((command) => client.send_command( + command.command, + command.args, + ).then(command.analytics))); + } + + return config; + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + + throw catchAclError(e); + } + } + + /** + * Get redis nodes to execute commands like "slowlog get", "slowlog clean", etc. for each node + * @param client + * @private + */ + private async getNodes(client: IORedis.Redis | IORedis.Cluster): Promise { + if (client instanceof IORedis.Cluster) { + return client.nodes(); + } + + return [client]; + } + + /** + * Get or create redis "common" client + * + * @param clientOptions + * @private + */ + private async getClient(clientOptions: IFindRedisClientInstanceByOptions) { + const { tool, instanceId } = clientOptions; + + const commonClient = this.redisService.getClientInstance({ instanceId, tool })?.client; + + if (commonClient && this.redisService.isClientConnected(commonClient)) { + return commonClient; + } + + return this.instancesBusinessService.connectToInstance( + clientOptions.instanceId, + clientOptions.tool, + true, + ); + } +} diff --git a/redisinsight/api/src/utils/cli-helper.spec.ts b/redisinsight/api/src/utils/cli-helper.spec.ts index d9065ad78a..aca200b05b 100644 --- a/redisinsight/api/src/utils/cli-helper.spec.ts +++ b/redisinsight/api/src/utils/cli-helper.spec.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { CommandParsingError, RedirectionParsingError } from 'src/modules/cli/constants/errors'; import { @@ -11,7 +12,10 @@ import { splitCliCommandLine, getBlockingCommands, checkRedirectionError, - parseRedirectionError, getRedisPipelineSummary, + parseRedirectionError, + getRedisPipelineSummary, + getASCIISafeStringFromBuffer, + getBufferFromSafeASCIIString, } from 'src/utils/cli-helper'; describe('Cli helper', () => { @@ -277,4 +281,62 @@ describe('Cli helper', () => { }); }); }); + + describe('getASCIISafeStringFromBuffer', () => { + const tests: Record[] = [ + { + buffer: Buffer.from([0x73, 0x69, 0x6d, 0x70, 0x6c, 0x65]), + string: 'simple', + unicode: 'simple', + }, + { + buffer: Buffer.from([0x45, 0x75, 0x72, 0x6f, 0x20, 0x2d, 0x20, 0xe2, 0x82, 0xac]), + string: 'Euro - \\xe2\\x82\\xac', + unicode: 'Euro - €', + }, + { + buffer: Buffer.from([ + 0xe2, 0x82, 0xac, // € + 0x20, 0x21, 0x3d, 0x20, // _!=_ + 0x5c, 0x65, 0x32, // \e2 + 0x5c, 0x78, 0x7a, 0x73, // \xzs + 0x5c, 0x30, 0x32, // \02 + ]), + string: '\\xe2\\x82\\xac != \\e2\\xzs\\02', + unicode: '€ != \\e2\\xzs\\02', + }, + { + buffer: Buffer.from([ + 0x02, 0x00, 0x00, 0x00, // special symbols + 0x7a, 0x69, 0x70, 0x63, 0x6f, 0x64, 0x65, // zipcode + ]), + string: '\\x02\\x00\\x00\\x00zipcode', + unicode: '\x02\x00\x00\x00zipcode', + }, + ]; + tests.forEach((test) => { + it(`should convert ${test.unicode} to buffer and to ASCII string representation`, async () => { + const str = getASCIISafeStringFromBuffer(test.buffer); + const buf = getBufferFromSafeASCIIString(test.string); + + expect(test.string).toEqual(str); + expect(test.buffer).toEqual(buf); + expect(test.unicode).toEqual(buf.toString()); + }); + }); + + it('test huge string timings', () => { + const buf = randomBytes(1024 * 1024); + + let startTime = Date.now(); + const str = getASCIISafeStringFromBuffer(buf); + console.log('To ASCII string took: ', Date.now() - startTime); + expect(Date.now() - startTime).toBeLessThan(5000); // usually takes ~1s + + startTime = Date.now(); + getBufferFromSafeASCIIString(str); + console.log('Back to Buffer took: ', Date.now() - startTime); + expect(Date.now() - startTime).toBeLessThan(5000); // usually takes ~0.7s + }); + }); }); diff --git a/redisinsight/api/src/utils/cli-helper.ts b/redisinsight/api/src/utils/cli-helper.ts index b77fd66805..e590514ac2 100644 --- a/redisinsight/api/src/utils/cli-helper.ts +++ b/redisinsight/api/src/utils/cli-helper.ts @@ -260,3 +260,28 @@ export const getASCIISafeStringFromBuffer = (reply: Buffer): string => { }); return result; }; + +/** + * Generates a Buffer from escaped string representation + * An opposite for getASCIISafeStringFromBuffer + * @param str + */ +export const getBufferFromSafeASCIIString = (str: string): Buffer => { + const bytes = []; + + for (let i = 0; i < str.length; i += 1) { + if (str[i] === '\\' && str[i + 1] === 'x') { + const hexString = str.substr(i + 2, 2); + if (isHex(hexString)) { + bytes.push(Buffer.from(hexString, 'hex')); + i += 3; + // eslint-disable-next-line no-continue + continue; + } + } + + bytes.push(Buffer.from(str[i])); + } + + return Buffer.concat(bytes); +}; diff --git a/redisinsight/api/src/validators/isObjectWithValues.validator.ts b/redisinsight/api/src/validators/isObjectWithValues.validator.ts new file mode 100644 index 0000000000..1e30dae8d1 --- /dev/null +++ b/redisinsight/api/src/validators/isObjectWithValues.validator.ts @@ -0,0 +1,35 @@ +import { + isObject, registerDecorator, ValidationArguments, ValidationOptions, +} from 'class-validator'; + +export function IsObjectWithValues( + valueValidators: ((value: unknown) => boolean)[], + validationOptions?: ValidationOptions, +) { + return (object: unknown, propertyName: string) => { + registerDecorator({ + name: 'IsObjectWithValues', + target: (object as any).constructor, + propertyName, + options: validationOptions, + + validator: { + validate(data: unknown) { + if (!isObject(data)) return false; + + for (const value of Object.values(data)) { + const isInvalidValue = valueValidators.some((validator) => !validator(value)); + if (isInvalidValue) { + return false; + } + } + + return true; + }, + defaultMessage(validationArguments?: ValidationArguments): string { + return `${validationArguments.property} should be a valid object with proper values`; + }, + }, + }); + }; +} diff --git a/redisinsight/api/test/api/keys/GET-instance-id-keys.test.ts b/redisinsight/api/test/api/keys/GET-instance-id-keys.test.ts index 79bd9cd31c..c1b7f63e85 100644 --- a/redisinsight/api/test/api/keys/GET-instance-id-keys.test.ts +++ b/redisinsight/api/test/api/keys/GET-instance-id-keys.test.ts @@ -5,6 +5,7 @@ import { before, deps, Joi, + _, requirements, validateApiCall, } from '../deps'; @@ -806,6 +807,30 @@ describe('GET /instance/:instanceId/keys', () => { ].map(mainCheckFn); }); }); + describe('non-ASCII keyName', () => { + before(async () => await rte.data.generateKeys(true)); + + [ + { + name: 'check keyname with non-ASCII symbols should be properly listed', + query: { + cursor: '0', + count: 200, + }, + responseSchema, + checkFn: async ({ body }) => { + const [stringNonASCIIKey] = _.filter(body.map( + nodeResult => nodeResult.keys.find((key) => key.name === constants.TEST_STRING_KEY_ASCII_UNICODE), + ), array => !!array); + + expect(stringNonASCIIKey.name).to.eq(constants.TEST_STRING_KEY_ASCII_UNICODE) + expect(stringNonASCIIKey.type).to.eq(constants.TEST_STRING_TYPE) + expect(stringNonASCIIKey.ttl).to.eq(-1) + expect(stringNonASCIIKey.size).to.gt(constants.TEST_STRING_KEY_ASCII_BUFFER.length) + } + }, + ].map(mainCheckFn); + }); }); describe('Big data', () => { describe('Exact search on huge keys number', () => { diff --git a/redisinsight/api/test/api/slowlog/DELETE-instance-id-slow_logs.test.ts b/redisinsight/api/test/api/slowlog/DELETE-instance-id-slow_logs.test.ts new file mode 100644 index 0000000000..4429a1f58f --- /dev/null +++ b/redisinsight/api/test/api/slowlog/DELETE-instance-id-slow_logs.test.ts @@ -0,0 +1,90 @@ +import { + expect, + describe, + it, + deps, + validateApiCall, + after, requirements, before, +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).delete(`/instance/${instanceId}/slow-logs`); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('DELETE /instance/:instanceId/slow-logs', () => { + describe('Common', () => { + beforeEach(async () => { + await rte.data.executeCommandAll('config', ['set', 'slowlog-log-slower-than', 0]); + await rte.client.get(constants.TEST_STRING_KEY_1); + }); + + after(async () => { + await rte.data.executeCommandAll('config', ['set', 'slowlog-log-slower-than', 10000]); + }); + + [ + { + name: 'Check that slowlog cleaned up', + before: async () => { + await rte.data.executeCommandAll('config', ['set', 'slowlog-log-slower-than', 10000000000]); + expect((await rte.client.send_command('slowlog', 'get')).length).to.gt(0); + }, + after: async () => { + expect((await rte.client.send_command('slowlog', 'get')).length).to.eq(0); + } + }, + { + name: 'Should return 404 not found when incorrect instance', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'Invalid database instance id.', + error: 'Not Found' + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should reset slowlog', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + }, + { + name: 'Should throw error if no permissions for "slowlog" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -slowlog') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/slowlog/GET-instance-id-slow_logs-config.test.ts b/redisinsight/api/test/api/slowlog/GET-instance-id-slow_logs-config.test.ts new file mode 100644 index 0000000000..33dbead73e --- /dev/null +++ b/redisinsight/api/test/api/slowlog/GET-instance-id-slow_logs-config.test.ts @@ -0,0 +1,85 @@ +import { + expect, + describe, + it, + Joi, + deps, + validateApiCall, + after, requirements, before, +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).get(`/instance/${instanceId}/slow-logs/config`); + +const responseSchema = Joi.object().keys({ + slowlogMaxLen: Joi.number().required(), + slowlogLogSlowerThan: Joi.number().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('GET /instance/:instanceId/slow-logs/config', () => { + describe('Common', () => { + [ + { + name: 'Should get slowlog config', + responseSchema, + checkFn: async ({ body }) => { + expect(body.slowlogMaxLen).to.gte(0); + expect(body.slowlogLogSlowerThan).to.gte(0); + }, + }, + { + name: 'Should return 404 not found when incorrect instance', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'Invalid database instance id.', + error: 'Not Found' + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should get slowlog config', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + }, + { + name: 'Should throw error if no permissions for "config" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -config') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/slowlog/GET-instance-id-slow_logs.test.ts b/redisinsight/api/test/api/slowlog/GET-instance-id-slow_logs.test.ts new file mode 100644 index 0000000000..d863b03d9b --- /dev/null +++ b/redisinsight/api/test/api/slowlog/GET-instance-id-slow_logs.test.ts @@ -0,0 +1,127 @@ +import { + expect, + describe, + it, + Joi, + deps, + validateApiCall, + after, requirements, before, +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).get(`/instance/${instanceId}/slow-logs`); + +const responseSchema = Joi.array().items(Joi.object().keys({ + id: Joi.number().required(), + time: Joi.number().required(), + durationUs: Joi.number().required(), + args: Joi.string().required(), + source: Joi.string().allow(''), + client: Joi.string().allow(''), +})).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('GET /instance/:instanceId/slow-logs', () => { + describe('Common', () => { + beforeEach(async () => { + await rte.data.executeCommandAll('config', ['set', 'slowlog-log-slower-than', 0]); + await rte.data.executeCommandAll('slowlog', ['reset']); + }); + + after(async () => { + await rte.data.executeCommandAll('config', ['set', 'slowlog-log-slower-than', 10000]); + }); + + [ + { + name: 'Should return 0 array when slowlog-log-slower-than is a huge value', + responseSchema, + before: async () => { + await rte.data.executeCommandAll('config', ['set', 'slowlog-log-slower-than', 1000000000]); + await rte.data.executeCommandAll('slowlog', ['reset']); + }, + checkFn: async ({ body }) => { + expect(body).to.eql([]); + }, + }, + { + name: 'Should return 1 + slave nodes array when slowlog-log-slower-than is a huge value', + responseSchema, + query: { + count: 1, + }, + before: async () => { + await rte.data.executeCommandAll('config', ['set', 'slowlog-log-slower-than', 0]); + await rte.data.executeCommandAll('slowlog', ['reset']); + }, + checkFn: async ({ body }) => { + expect(body.length).to.eql(rte.client.nodes ? rte.client.nodes().length : 1); + }, + }, + { + name: 'Should get slow logs including "set" command inside', + responseSchema, + before: async () => { + await rte.client.set(constants.TEST_STRING_KEY_1, constants.GENERATE_BIG_TEST_STRING_VALUE(0.1)); + }, + checkFn: async ({ body }) => { + expect(body.length).to.gt(0); + const stringSlowLog = body.find((log) => log.args.startsWith(`set ${constants.TEST_STRING_KEY_1}`)); + expect(stringSlowLog.durationUs).to.gt(0); + }, + }, + { + name: 'Should return 404 not found when incorrect instance', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'Invalid database instance id.', + error: 'Not Found' + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should fetch slowlog', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + }, + { + name: 'Should throw error if no permissions for "slowlog" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -slowlog') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/slowlog/PATCH-instance-id-slow_logs-config.test.ts b/redisinsight/api/test/api/slowlog/PATCH-instance-id-slow_logs-config.test.ts new file mode 100644 index 0000000000..c8507de890 --- /dev/null +++ b/redisinsight/api/test/api/slowlog/PATCH-instance-id-slow_logs-config.test.ts @@ -0,0 +1,181 @@ +import { + expect, + describe, + it, + Joi, + deps, + validateApiCall, + after, requirements, before, generateInvalidDataTestCases, validateInvalidDataTestCase, +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).patch(`/instance/${instanceId}/slow-logs/config`); + +const dataSchema = Joi.object({ + slowlogMaxLen: Joi.number().min(0).messages({ + 'array.sparse': 'entries must be either object or array', + 'array.base': 'property {#label} must be either object or array', + }), + slowlogLogSlowerThan: Joi.number().min(-1), +}).strict(); + +const validInputData = { + slowlogMaxLen: 128, + slowlogLogSlowerThan: 10000, +}; + +const responseSchema = Joi.object().keys({ + slowlogMaxLen: Joi.number().required(), + slowlogLogSlowerThan: Joi.number().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('PATCH /instance/:instanceId/slow-logs/config', () => { + before(async () => { + await rte.data.executeCommand('config', ['set', 'slowlog-log-slower-than', 10000]); + await rte.data.executeCommand('config', ['set', 'slowlog-max-len', 128]); + }); + + after(async () => { + await rte.data.executeCommand('config', ['set', 'slowlog-log-slower-than', 10000]); + await rte.data.executeCommand('config', ['set', 'slowlog-max-len', 128]); + }); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Standalone', () => { + requirements('rte.type=STANDALONE'); + + describe('Common', () => { + [ + { + name: 'Should NOT change anything', + responseSchema, + responseBody: { + slowlogMaxLen: 128, + slowlogLogSlowerThan: 10000, + }, + }, + { + name: 'Should change only slowlog-max-len', + data: { + slowlogMaxLen: 100, + }, + responseSchema, + responseBody: { + slowlogMaxLen: 100, + slowlogLogSlowerThan: 10000, + }, + }, + { + name: 'Should change only slowlog-log-slower-than', + data: { + slowlogLogSlowerThan: 100, + }, + responseSchema, + responseBody: { + slowlogMaxLen: 100, + slowlogLogSlowerThan: 100, + }, + }, + { + name: 'Should change both', + data: { + slowlogMaxLen: 128, + slowlogLogSlowerThan: 10000, + }, + responseSchema, + responseBody: { + slowlogMaxLen: 128, + slowlogLogSlowerThan: 10000, + }, + }, + { + name: 'Should return 404 not found when incorrect instance', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'Invalid database instance id.', + error: 'Not Found' + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should get slowlog config', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + }, + { + name: 'Should throw error if no permissions for "config" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -config') + }, + ].map(mainCheckFn); + }); + }); + + describe('Cluster', () => { + requirements('rte.type=CLUSTER'); + + [ + { + name: 'Should return 400 since there is no way to modify cluster config at the moment', + data: { + slowlogMaxLen: 1, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + message: 'Configuration slowlog for cluster is deprecated', + error: 'Bad Request' + }, + }, + { + name: 'Should return 404 not found when incorrect instance', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'Invalid database instance id.', + error: 'Not Found' + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/stream/POST-instance-id-streams-entries-get.test.ts b/redisinsight/api/test/api/stream/POST-instance-id-streams-entries-get.test.ts new file mode 100644 index 0000000000..37f4f65724 --- /dev/null +++ b/redisinsight/api/test/api/stream/POST-instance-id-streams-entries-get.test.ts @@ -0,0 +1,283 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/streams/entries/get`); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + start: Joi.string(), + end: Joi.string(), + count: Joi.number().integer().min(1).allow(true), + sortOrder: Joi.string().valid('DESC', 'ASC'), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + start: '-', + end: '+', + count: 15, + sortOrder: 'DESC', +}; + +const entrySchema = Joi.object().keys({ + id: Joi.string().required(), + fields: Joi.object().required(), +}); + +const responseSchema = Joi.object().keys({ + keyName: Joi.string().required(), + total: Joi.number().integer().required(), + lastGeneratedId: Joi.string().required(), + firstEntry: entrySchema.required(), + lastEntry: entrySchema.required(), + entries: Joi.array().items(entrySchema.required()).required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/streams/entries/get', () => { + before(async () => await rte.data.generateKeys(true)); + before(async () => await rte.data.generateHugeStream(10000, false)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + let offsetEntryId; + [ + { + name: 'Should query 500 entries in the DESC order by default', + data: { + keyName: constants.TEST_STREAM_HUGE_KEY, + }, + responseSchema, + checkFn: async ({ body }) => { + offsetEntryId = body.entries[99].id; + expect(body.keyName).to.eql(constants.TEST_STREAM_HUGE_KEY); + expect(body.total).to.eql(10000); + expect(body.entries.length).to.eql(500); + body.entries.forEach((entry, i) => { + expect(entry.id).to.be.a('string'); + expect(entry.fields).to.eql({ + [`f_${9999 - i}`]: `v_${9999 - i}`, + }); + }); + }, + }, + { + name: 'Should query 10 entries in the DESC order starting from 100th entry', + data: () => ({ + keyName: constants.TEST_STREAM_HUGE_KEY, + start: '-', + end: offsetEntryId, + count: 10, + }), + responseSchema, + checkFn: async ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_STREAM_HUGE_KEY); + expect(body.total).to.eql(10000); + expect(body.entries.length).to.eql(10); + body.entries.forEach((entry, i) => { + expect(entry.id).to.be.a('string'); + expect(entry.fields).to.eql({ + [`f_${9900 - i}`]: `v_${9900 - i}`, + }); + }); + }, + }, + { + name: 'Should query 500 entries in the ASC order', + data: { + keyName: constants.TEST_STREAM_HUGE_KEY, + sortOrder: 'ASC', + }, + responseSchema, + checkFn: async ({ body }) => { + offsetEntryId = body.entries[99].id; + expect(body.keyName).to.eql(constants.TEST_STREAM_HUGE_KEY); + expect(body.total).to.eql(10000); + expect(body.entries.length).to.eql(500); + body.entries.forEach((entry, i) => { + expect(entry.id).to.be.a('string'); + expect(entry.fields).to.eql({ + [`f_${i}`]: `v_${i}`, + }); + }); + }, + }, + { + name: 'Should query 10 entries in the ASC order starting from 100th entry', + data: () => ({ + keyName: constants.TEST_STREAM_HUGE_KEY, + start: offsetEntryId, + end: '+', + count: 10, + sortOrder: 'ASC', + }), + responseSchema, + checkFn: async ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_STREAM_HUGE_KEY); + expect(body.total).to.eql(10000); + expect(body.entries.length).to.eql(10); + body.entries.forEach((entry, i) => { + expect(entry.id).to.be.a('string'); + expect(entry.fields).to.eql({ + [`f_${99 + i}`]: `v_${99 + i}`, + }); + }); + }, + }, + { + name: 'Should return BadRequest when try to work with non-stream type', + data: { + keyName: constants.TEST_STRING_KEY_1, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return bad request', + data: { + keyName: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_STREAM_HUGE_KEY, + offset: 45, + count: 45, + sortOrder: 'ASC', + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should remove all members and key', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STREAM_HUGE_KEY, + offset: 0, + count: 15, + sortOrder: 'ASC', + }, + responseSchema, + }, + { + name: 'Should throw error if no permissions for "xinfo" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STREAM_HUGE_KEY, + offset: 0, + count: 15, + sortOrder: 'ASC', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xinfo') + }, + { + name: 'Should throw error if no permissions for "xrange" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STREAM_HUGE_KEY, + offset: 0, + count: 15, + sortOrder: 'ASC', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xrange') + }, + { + name: 'Should throw error if no permissions for "xrevrange" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STREAM_HUGE_KEY, + offset: 0, + count: 15, + sortOrder: 'DESC', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xrevrange') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/stream/POST-instance-id-streams-entries.test.ts b/redisinsight/api/test/api/stream/POST-instance-id-streams-entries.test.ts new file mode 100644 index 0000000000..f6bbedf32f --- /dev/null +++ b/redisinsight/api/test/api/stream/POST-instance-id-streams-entries.test.ts @@ -0,0 +1,305 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/streams/entries`); + +const entrySchema = Joi.object().keys({ + id: Joi.string().label('entries.0.id').required(), + fields: Joi.object().label('entries.0.fields').required() + .messages({ + 'object.base': '{#label} must be an object', + }), +}); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + entries: Joi.array().items(entrySchema).required().messages({ + 'array.sparse': 'entries must be either object or array', + 'array.base': 'property {#label} must be either object or array', + }), +}).strict(); + +const responseSchema = Joi.object().keys({ + keyName: Joi.string().required(), + entries: Joi.array().items(Joi.string()).required(), +}).required(); + +const validInputData = { + keyName: constants.TEST_STREAM_KEY_1, + entries: [ + { + id: '*', + fields: { + [constants.TEST_STREAM_FIELD_1]: constants.TEST_STREAM_VALUE_1, + [constants.TEST_STREAM_FIELD_2]: constants.TEST_STREAM_VALUE_2, + } + } + ], +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/streams/entries', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + + // extra validation + [ + { + name: 'Should throw 400 error when passed field value as another object', + data: { + keyName: constants.TEST_STREAM_KEY_1, + entries: [ + { + id: '*', + fields: { + [constants.TEST_STREAM_FIELD_1]: { some: constants.TEST_STREAM_FIELD_1 }, + } + } + ] + }, + statusCode: 400, + checkFn: ({ body }) => { + expect(body.message[0]).to.have.string('must be an object with string values') + } + }, + { + name: 'Should throw 400 error when passed field value as another boolean', + data: { + keyName: constants.TEST_STREAM_KEY_1, + entries: [ + { + id: '*', + fields: { + [constants.TEST_STREAM_FIELD_1]: true, + } + } + ] + }, + statusCode: 400, + checkFn: ({ body }) => { + expect(body.message[0]).to.have.string('must be an object with string values') + } + }, + { + name: 'Should throw 400 error when passed field value as another number', + data: { + keyName: constants.TEST_STREAM_KEY_1, + entries: [ + { + id: '*', + fields: { + [constants.TEST_STREAM_FIELD_1]: 100, + } + } + ] + }, + statusCode: 400, + checkFn: ({ body }) => { + expect(body.message[0]).to.have.string('must be an object with string values') + } + }, + { + name: 'Should throw 400 error when passed field value as another null', + data: { + keyName: constants.TEST_STREAM_KEY_1, + entries: [ + { + id: '*', + fields: { + [constants.TEST_STREAM_FIELD_1]: null, + } + } + ] + }, + statusCode: 400, + checkFn: ({ body }) => { + expect(body.message[0]).to.have.string('must be an object with string values') + } + }, + ].map(mainCheckFn) + }); + + describe('Common', () => { + [ + { + name: 'Should add entry', + data: { + keyName: constants.TEST_STREAM_KEY_1, + entries: [ + { + id: '*', + fields: { + [constants.TEST_STREAM_FIELD_1]: constants.TEST_STREAM_FIELD_1, + } + } + ] + }, + responseSchema, + after: async () => { + expect(await rte.client.xlen(constants.TEST_STREAM_KEY_1)).to.eq(2); + const [entry] = await rte.client.xrevrange(constants.TEST_STREAM_KEY_1, '+', '-', 'COUNT', 1); + expect(entry[1]).to.eql([constants.TEST_STREAM_FIELD_1, constants.TEST_STREAM_FIELD_1]); + }, + }, + { + name: 'Should add multiple entries and multiple fields', + data: { + keyName: constants.TEST_STREAM_KEY_1, + entries: [ + { + id: '*', + fields: { + [constants.TEST_STREAM_FIELD_1]: constants.TEST_STREAM_FIELD_1, + [constants.TEST_STREAM_FIELD_2]: constants.TEST_STREAM_FIELD_2, + } + }, + { + id: '*', + fields: { + [constants.TEST_STREAM_VALUE_1]: constants.TEST_STREAM_VALUE_1, + [constants.TEST_STREAM_VALUE_2]: constants.TEST_STREAM_VALUE_2, + } + }, + ] + }, + responseSchema, + after: async () => { + expect(await rte.client.xlen(constants.TEST_STREAM_KEY_1)).to.eq(4); + const [entry1, entry2] = await rte.client.xrevrange(constants.TEST_STREAM_KEY_1, '+', '-', 'COUNT', 2); + expect(entry1[1]).to.eql( + [ + constants.TEST_STREAM_VALUE_1, constants.TEST_STREAM_VALUE_1, + constants.TEST_STREAM_VALUE_2, constants.TEST_STREAM_VALUE_2, + ] + ); + expect(entry2[1]).to.eql( + [ + constants.TEST_STREAM_FIELD_1, constants.TEST_STREAM_FIELD_1, + constants.TEST_STREAM_FIELD_2, constants.TEST_STREAM_FIELD_2, + ] + ); + }, + }, + { + name: 'Should return BadRequest when try to work with non-stream type', + data: { + ...validInputData, + keyName: constants.TEST_STRING_KEY_1, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should return BadRequest when id specified is less then the latest one', + data: { + ...validInputData, + entries: [ + { + ...validInputData.entries[0], + id: '100', + } + ] + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + ...validInputData, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should add entries', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + responseSchema, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + keyName: constants.getRandomString(), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "xadd" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xadd') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/stream/POST-instance-id-streams.test.ts b/redisinsight/api/test/api/stream/POST-instance-id-streams.test.ts new file mode 100644 index 0000000000..62e6083fd4 --- /dev/null +++ b/redisinsight/api/test/api/stream/POST-instance-id-streams.test.ts @@ -0,0 +1,233 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/streams`); + +const entrySchema = Joi.object().keys({ + id: Joi.string().label('entries.0.id').required(), + fields: Joi.object().label('entries.0.fields').required() + .messages({ + 'object.base': '{#label} must be an object', + }), +}); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + entries: Joi.array().items(entrySchema).required().messages({ + 'array.sparse': 'entries must be either object or array', + 'array.base': 'property {#label} must be either object or array', + }), + expire: Joi.number().integer().allow(null).min(1).max(2147483647), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + entries: [ + { + id: constants.TEST_STREAM_ID_1, + fields: { + [constants.TEST_STREAM_FIELD_1]: constants.TEST_STREAM_VALUE_1, + [constants.TEST_STREAM_FIELD_2]: constants.TEST_STREAM_VALUE_2, + } + } + ], + expire: constants.TEST_STREAM_EXPIRE_1, +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/streams', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + beforeEach(async () => { + await rte.client.del(constants.TEST_STREAM_KEY_1); + }); + + [ + { + name: 'Should create stream with single entry and single field', + data: { + keyName: constants.TEST_STREAM_KEY_1, + entries: [ + { + id: '*', + fields: { + [constants.TEST_STREAM_FIELD_1]: constants.TEST_STREAM_VALUE_1, + } + } + ] + }, + statusCode: 201, + after: async () => { + const entries = await rte.client.xrange(constants.TEST_STREAM_KEY_1, '-', '+'); + expect(entries[0][1]).to.eql([constants.TEST_STREAM_FIELD_1, constants.TEST_STREAM_VALUE_1]); + }, + }, + { + name: 'Should create stream with ttl', + data: { + keyName: constants.TEST_STREAM_KEY_1, + entries: [ + { + id: '*', + fields: { + [constants.TEST_STREAM_FIELD_1]: constants.TEST_STREAM_VALUE_1, + }, + }, + ], + expire: constants.TEST_STREAM_EXPIRE_1, + }, + statusCode: 201, + after: async () => { + const ttl = await rte.client.ttl(constants.TEST_STREAM_KEY_1); + expect(ttl).to.lte(constants.TEST_STREAM_EXPIRE_1); + expect(ttl).to.gt(0); + + const entries = await rte.client.xrange(constants.TEST_STREAM_KEY_1, '-', '+'); + expect(entries[0][1]).to.eql([constants.TEST_STREAM_FIELD_1, constants.TEST_STREAM_VALUE_1]); + }, + }, + { + name: 'Should create stream with multiple entries and multiple fields', + data: { + keyName: constants.TEST_STREAM_KEY_1, + entries: [ + { + id: '*', + fields: { + [constants.TEST_STREAM_FIELD_1]: constants.TEST_STREAM_VALUE_1, + [constants.TEST_STREAM_FIELD_2]: constants.TEST_STREAM_VALUE_2, + } + }, + { + id: '*', + fields: { + [constants.TEST_STREAM_FIELD_1]: constants.TEST_STREAM_VALUE_1, + [constants.TEST_STREAM_FIELD_2]: constants.TEST_STREAM_VALUE_2, + } + }, + ] + }, + statusCode: 201, + after: async () => { + const entries = await rte.client.xrange(constants.TEST_STREAM_KEY_1, '-', '+'); + expect(entries[0][1]).to.eql([ + constants.TEST_STREAM_FIELD_1, constants.TEST_STREAM_VALUE_1, + constants.TEST_STREAM_FIELD_2, constants.TEST_STREAM_VALUE_2, + ]); + expect(entries[1][1]).to.eql([ + constants.TEST_STREAM_FIELD_1, constants.TEST_STREAM_VALUE_1, + constants.TEST_STREAM_FIELD_2, constants.TEST_STREAM_VALUE_2, + ]); + }, + }, + { + name: 'Should return Conflict error when trying to create key with existing key name', + data: { + ...validInputData, + keyName: constants.TEST_STRING_KEY_1, + }, + statusCode: 409, + responseBody: { + statusCode: 409, + error: 'Conflict', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + ...validInputData, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create stream', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + statusCode: 201, + data: { + ...validInputData, + keyName: constants.getRandomString(), + }, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + keyName: constants.getRandomString(), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "xadd" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + keyName: constants.getRandomString(), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xadd') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index 2d7dee7d39..80ae28a1d1 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; import { randomBytes } from 'crypto'; +import { getASCIISafeStringFromBuffer, getBufferFromSafeASCIIString } from "src/utils/cli-helper"; const TEST_RUN_ID = `=${uuidv4()}`; const KEY_TTL = 100; @@ -108,6 +109,10 @@ export const constants = { TEST_STRING_KEY_2: TEST_RUN_ID + '_string_2' + CLUSTER_HASH_SLOT, TEST_STRING_VALUE_2: TEST_RUN_ID + '_value_2', TEST_STRING_EXPIRE_2: KEY_TTL, + TEST_STRING_KEY_ASCII: getASCIISafeStringFromBuffer(getBufferFromSafeASCIIString(TEST_RUN_ID + '_str_ascii_€' + CLUSTER_HASH_SLOT)), + TEST_STRING_KEY_ASCII_BUFFER: getBufferFromSafeASCIIString(TEST_RUN_ID + '_str_ascii_€' + CLUSTER_HASH_SLOT), + TEST_STRING_KEY_ASCII_UNICODE: TEST_RUN_ID + '_str_ascii_€' + CLUSTER_HASH_SLOT, + TEST_STRING_KEY_ASCII_VALUE: TEST_RUN_ID + '_value_ascii', // Redis List TEST_LIST_TYPE: 'list', @@ -162,6 +167,14 @@ export const constants = { TEST_STREAM_KEY_1: TEST_RUN_ID + '_stream_1' + CLUSTER_HASH_SLOT, TEST_STREAM_DATA_1: TEST_RUN_ID + '_stream_data_1', TEST_STREAM_DATA_2: TEST_RUN_ID + '_stream_data_2', + TEST_STREAM_ID_1: '100-0', + TEST_STREAM_FIELD_1: TEST_RUN_ID + '_stream_field_1', + TEST_STREAM_VALUE_1: TEST_RUN_ID + '_stream_value_1', + TEST_STREAM_ID_2: '200-0', + TEST_STREAM_FIELD_2: TEST_RUN_ID + '_stream_field_2', + TEST_STREAM_VALUE_2: TEST_RUN_ID + '_stream_value_2', + TEST_STREAM_EXPIRE_1: KEY_TTL, + TEST_STREAM_HUGE_KEY: TEST_RUN_ID + '_stream_huge' + CLUSTER_HASH_SLOT, // ReJSON-RL TEST_REJSON_TYPE: 'ReJSON-RL', diff --git a/redisinsight/api/test/helpers/data/redis.ts b/redisinsight/api/test/helpers/data/redis.ts index 0ec0812448..f383726a22 100644 --- a/redisinsight/api/test/helpers/data/redis.ts +++ b/redisinsight/api/test/helpers/data/redis.ts @@ -15,6 +15,16 @@ export const initDataHelper = (rte) => { })) : client.send_command(args.shift(), ...args); }; + const executeCommandAll = async (...args: string[]): Promise => { + return client.nodes ? Promise.all(client.nodes().map(async (node) => { + try { + return node.send_command(...args); + } catch (e) { + return null; + } + })) : client.send_command(args.shift(), ...args); + }; + const setAclUserRules = async ( rules: string, ): Promise => { @@ -63,6 +73,7 @@ export const initDataHelper = (rte) => { await generateZSets(); await generateHashes(); await generateReJSONs(); + await generateStreams(); }; const insertKeysBasedOnEnv = async (pipeline, forcePipeline: boolean = false) => { @@ -102,6 +113,7 @@ export const initDataHelper = (rte) => { await client.set(constants.TEST_STRING_KEY_1, constants.TEST_STRING_VALUE_1); await client.set(constants.TEST_STRING_KEY_2, constants.TEST_STRING_VALUE_2, 'EX', constants.TEST_STRING_EXPIRE_2); + await client.set(constants.TEST_STRING_KEY_ASCII_BUFFER, constants.TEST_STRING_KEY_ASCII_VALUE); }; // List @@ -210,6 +222,33 @@ export const initDataHelper = (rte) => { await executeCommand('json.set', constants.TEST_REJSON_KEY_3, '.', JSON.stringify(constants.TEST_REJSON_VALUE_3)); }; + // Streams + const generateStreams = async (clean: boolean = false) => { + if (clean) { + await truncate(); + } + + await client.xadd(constants.TEST_STREAM_KEY_1, '*', constants.TEST_STREAM_FIELD_1, constants.TEST_STREAM_VALUE_1) + }; + + const generateHugeStream = 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(['xadd', `${constants.TEST_STREAM_HUGE_KEY}`, '*', `f_${inserted}`, `v_${inserted}`]); + } + + await insertKeysBasedOnEnv(pipeline); + } while (inserted < number) + }; + const generateHugeNumberOfFieldsForHashKey = async (number: number = 100000, clean: boolean) => { if (clean) { await truncate(); @@ -281,17 +320,28 @@ export const initDataHelper = (rte) => { ], number, clean); }; + const getClientNodes = () => { + if (client.nodes) { + return client.nodes(); + } else { + return [client]; + } + } + return { executeCommand, + executeCommandAll, setAclUserRules, truncate, generateKeys, generateHugeNumberOfFieldsForHashKey, generateHugeNumberOfTinyStringKeys, + generateHugeStream, generateNKeys, generateNReJSONs, generateNTimeSeries, generateNStreams, generateNGraphs, + getClientNodes, } } diff --git a/redisinsight/api/test/helpers/test.ts b/redisinsight/api/test/helpers/test.ts index 6bdd18500c..8d0c83679a 100644 --- a/redisinsight/api/test/helpers/test.ts +++ b/redisinsight/api/test/helpers/test.ts @@ -45,7 +45,7 @@ export const validateApiCall = async function ({ // data to send with POST, PUT etc if (data) { - request.send(data); + request.send(typeof data === 'function' ? data() : data); } // data to send with url query string diff --git a/redisinsight/main.dev.ts b/redisinsight/main.dev.ts index 919676c326..f5dba01ec2 100644 --- a/redisinsight/main.dev.ts +++ b/redisinsight/main.dev.ts @@ -43,6 +43,24 @@ if (process.env.NODE_ENV !== 'production') { log.info('App starting.....'); +// Replacing sensitive data inside error message +// todo: split main.ts file and make proper structure +const wrapErrorMessageSensitiveData = (e: Error) => { + const regexp = /(\/[^\s]*\/)|(\\[^\s]*\\)/ig; + e.message = e.message.replace(regexp, (_match, unixPath, winPath): string => { + if (unixPath) { + return '*****/'; + } + if (winPath) { + return '*****\\'; + } + + return _match; + }); + + return e; +}; + export default class AppUpdater { constructor(url: string = '') { log.info('AppUpdater initialization'); @@ -54,7 +72,7 @@ export default class AppUpdater { url, }); } catch (error) { - log.error(error); + log.error(wrapErrorMessageSensitiveData(error)); } autoUpdater.checkForUpdatesAndNotify(); @@ -78,7 +96,7 @@ const installExtensions = async () => { loadExtensionOptions: { allowFileAccess: true }, }) .then((name) => console.log(`Added Extension: ${name}`)) - .catch((err) => console.log('An error occurred: ', err.toString())); + .catch((err) => console.log('An error occurred: ', wrapErrorMessageSensitiveData(err).toString())); }; let store: Store; @@ -106,7 +124,7 @@ const launchApiServer = async () => { log.info('Available port:', detectPortConst); backendGracefulShutdown = await server(); } catch (error) { - log.error('Catch server error:', error); + log.error('Catch server error:', wrapErrorMessageSensitiveData(error)); } }; @@ -335,7 +353,7 @@ app.whenReady() .then(bootstrap) .then(createSplashScreen) .then(createWindow) - .catch(console.log); + .catch((e) => console.log(wrapErrorMessageSensitiveData(e))); app.on('activate', () => { // On macOS it's common to re-create a window in the app when the @@ -359,8 +377,8 @@ autoUpdater.on('update-not-available', () => { sendStatusToWindow('Update not available.'); store?.set(ElectronStorageItem.isUpdateAvailable, false); }); -autoUpdater.on('error', (err: string) => { - sendStatusToWindow(`Error in auto-updater. ${err}`); +autoUpdater.on('error', (err: Error) => { + sendStatusToWindow(`Error in auto-updater. ${wrapErrorMessageSensitiveData(err)}`); }); autoUpdater.on('download-progress', (progressObj) => { let logMessage = `Download speed: ${progressObj.bytesPerSecond}`; diff --git a/redisinsight/package.json b/redisinsight/package.json index 0b0ec097f0..6b0fdfe3a2 100644 --- a/redisinsight/package.json +++ b/redisinsight/package.json @@ -2,7 +2,7 @@ "name": "redisinsight", "productName": "RedisInsight", "private": true, - "version": "2.0.6", + "version": "2.2.0", "description": "RedisInsight", "main": "./main.prod.js", "author": { diff --git a/redisinsight/ui/.eslintrc.js b/redisinsight/ui/.eslintrc.js index 4f532f0556..004cc971f6 100644 --- a/redisinsight/ui/.eslintrc.js +++ b/redisinsight/ui/.eslintrc.js @@ -54,6 +54,7 @@ module.exports = { 'react/jsx-props-no-spreading': 'off', 'react/require-default-props': 'off', 'react/prop-types': 1, + 'react/jsx-one-expression-per-line': 'off', '@typescript-eslint/comma-dangle': 'off', '@typescript-eslint/no-shadow': 'off', '@typescript-eslint/no-unused-expressions': 'off', @@ -65,6 +66,28 @@ module.exports = { 'no-param-reassign': ['error', { props: false }], 'sonarjs/no-duplicate-string': 'off', 'sonarjs/cognitive-complexity': [1, 15], - 'sonarjs/no-identical-functions': [0, 5] + 'sonarjs/no-identical-functions': [0, 5], + 'import/order': [ + 1, + { + groups: [ + 'external', + 'builtin', + 'internal', + 'sibling', + 'parent', + 'index', + ], + pathGroups: [ + { + pattern: '{.,..}/*.scss', // same directory only + // pattern: '{.,..}/**/*\.scss' // same & outside directories (e.g. import '../foo/foo.scss') + group: 'object', + position: 'after' + } + ], + warnOnUnassignedImports: true + }, + ], }, } diff --git a/redisinsight/ui/src/assets/img/sidebar/slowlog.svg b/redisinsight/ui/src/assets/img/sidebar/slowlog.svg new file mode 100644 index 0000000000..e960c07846 --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/slowlog.svg @@ -0,0 +1,4 @@ + + + + diff --git a/redisinsight/ui/src/assets/img/sidebar/slowlog_active.svg b/redisinsight/ui/src/assets/img/sidebar/slowlog_active.svg new file mode 100644 index 0000000000..5d71c92a51 --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/slowlog_active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.spec.tsx index 228169d7fb..d2a1982c68 100644 --- a/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.spec.tsx +++ b/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.spec.tsx @@ -15,7 +15,7 @@ import { sendCliClusterCommandAction, } from 'uiSrc/slices/cli/cli-output' import { processCliClient } from 'uiSrc/slices/cli/cli-settings' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import CliBodyWrapper from './CliBodyWrapper' @@ -34,8 +34,8 @@ jest.mock('uiSrc/services', () => ({ }, })) -jest.mock('uiSrc/slices/instances', () => ({ - ...jest.requireActual('uiSrc/slices/instances'), +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), connectedInstanceSelector: jest.fn().mockReturnValue({ id: '123', connectionType: 'STANDALONE', diff --git a/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx b/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx index 2def63ab33..8d3dc6e904 100644 --- a/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx +++ b/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx @@ -23,7 +23,7 @@ import { CommandMonitor } from 'uiSrc/constants' import { getCommandRepeat, isRepeatCountCorrect } from 'uiSrc/utils' import { ConnectionType } from 'uiSrc/slices/interfaces' import { ClusterNodeRole } from 'uiSrc/slices/interfaces/cli' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { checkUnsupportedCommand, clearOutput, cliCommandOutput } from 'uiSrc/utils/cliHelper' import { SendClusterCommandDto } from 'apiSrc/modules/cli/dto/cli.dto' diff --git a/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.spec.tsx index 14cf5b8e96..b702f687db 100644 --- a/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.spec.tsx +++ b/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.spec.tsx @@ -12,7 +12,7 @@ import { } from 'uiSrc/utils/test-utils' import { BrowserStorageItem } from 'uiSrc/constants' import { processCliClient, resetCliSettings, toggleCli } from 'uiSrc/slices/cli/cli-settings' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { sessionStorageService } from 'uiSrc/services' import { resetOutputLoading } from 'uiSrc/slices/cli/cli-output' import CliHeader from './CliHeader' @@ -24,8 +24,8 @@ beforeEach(() => { store.clearActions() }) -jest.mock('uiSrc/slices/instances', () => ({ - ...jest.requireActual('uiSrc/slices/instances'), +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), connectedInstanceSelector: jest.fn().mockReturnValue({ host: 'localhost', port: 6379, diff --git a/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.tsx b/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.tsx index 33280813d0..47c06ec486 100644 --- a/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.tsx +++ b/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.tsx @@ -19,7 +19,7 @@ import { } from 'uiSrc/slices/cli/cli-settings' import { BrowserStorageItem } from 'uiSrc/constants' import { sessionStorageService } from 'uiSrc/services' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { outputSelector, resetOutputLoading } from 'uiSrc/slices/cli/cli-output' diff --git a/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx b/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx index ea3609e750..7ee60cc5e7 100644 --- a/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx +++ b/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx @@ -5,7 +5,7 @@ import { connectedInstanceOverviewSelector, connectedInstanceSelector, getDatabaseConfigInfoAction -} from 'uiSrc/slices/instances' +} from 'uiSrc/slices/instances/instances' import { ThemeContext } from 'uiSrc/contexts/themeContext' import { getOverviewMetrics } from './components/OverviewMetrics' diff --git a/redisinsight/ui/src/components/inline-item-editor/InlineItemEditor.tsx b/redisinsight/ui/src/components/inline-item-editor/InlineItemEditor.tsx index 253bca5e02..8e77338c8b 100644 --- a/redisinsight/ui/src/components/inline-item-editor/InlineItemEditor.tsx +++ b/redisinsight/ui/src/components/inline-item-editor/InlineItemEditor.tsx @@ -92,20 +92,17 @@ const InlineItemEditor = (props: Props) => { }, []) const handleChangeValue = (e: ChangeEvent) => { - const newValue = e.target.value + let newValue = e.target.value + if (validation) { + newValue = validation(newValue) + } if (disableByValidation) { setIsError(disableByValidation(newValue)) } - if (validation) { - const validatedValue = validation(newValue) - setValue(validatedValue) - onChange?.(validatedValue) - } else { - setValue(newValue) - onChange?.(newValue) - } + setValue(newValue) + onChange?.(newValue) } const handleClickOutside = (event: any) => { @@ -187,7 +184,7 @@ const InlineItemEditor = (props: Props) => { aria-label="Cancel editing" className={cx(styles.btn, styles.declineBtn)} onClick={onDecline} - disabled={isLoading} + isDisabled={isLoading} data-testid="cancel-btn" /> { type="submit" aria-label="Apply" className={cx(styles.btn, styles.applyBtn)} - disabled={isDisabledApply()} + isDisabled={isDisabledApply()} data-testid="apply-btn" /> diff --git a/redisinsight/ui/src/components/inline-item-editor/styles.module.scss b/redisinsight/ui/src/components/inline-item-editor/styles.module.scss index 45ba1739c1..0bf8c3f324 100644 --- a/redisinsight/ui/src/components/inline-item-editor/styles.module.scss +++ b/redisinsight/ui/src/components/inline-item-editor/styles.module.scss @@ -83,7 +83,7 @@ color: var(--euiColorColorDanger) !important; } -.applyBtn:hover { +.applyBtn:hover:not([class*="isDisabled"]) { color: var(--euiColorPrimary) !important; } diff --git a/redisinsight/ui/src/components/input-field-sentinel/InputFieldSentinel.tsx b/redisinsight/ui/src/components/input-field-sentinel/InputFieldSentinel.tsx index 3837ef3b9c..59ee3d571f 100644 --- a/redisinsight/ui/src/components/input-field-sentinel/InputFieldSentinel.tsx +++ b/redisinsight/ui/src/components/input-field-sentinel/InputFieldSentinel.tsx @@ -3,7 +3,7 @@ import { omit } from 'lodash' import React, { useState } from 'react' import cx from 'classnames' import { useDebouncedEffect } from 'uiSrc/services' -import { validateDatabaseNumber } from 'uiSrc/utils' +import { validateNumber } from 'uiSrc/utils' import styles from './styles.module.scss' @@ -77,7 +77,7 @@ const InputFieldSentinel = (props: Props) => { compressed type="text" value={value} - onChange={(e) => handleChange(validateDatabaseNumber(e.target?.value))} + onChange={(e) => handleChange(validateNumber(e.target?.value))} data-testid="sentinel-input-number" /> )} diff --git a/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx b/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx index f2ae216a31..6282dc4645 100644 --- a/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx +++ b/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx @@ -7,7 +7,7 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiToolTip } from '@ import { Pages } from 'uiSrc/constants' import { BuildType } from 'uiSrc/constants/env' import { ConnectionType } from 'uiSrc/slices/interfaces' -import { connectedInstanceOverviewSelector, connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceOverviewSelector, connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { appInfoSelector } from 'uiSrc/slices/app/info' import ShortInstanceInfo from 'uiSrc/components/instance-header/components/ShortInstanceInfo' import DatabaseOverviewWrapper from 'uiSrc/components/database-overview/DatabaseOverviewWrapper' diff --git a/redisinsight/ui/src/components/instance-header/styles.module.scss b/redisinsight/ui/src/components/instance-header/styles.module.scss index d221a9faf5..d36793f249 100644 --- a/redisinsight/ui/src/components/instance-header/styles.module.scss +++ b/redisinsight/ui/src/components/instance-header/styles.module.scss @@ -33,7 +33,7 @@ display: inline-block !important; overflow: hidden; font-size: 16px; - line-height: 18px; + line-height: 20px; font-weight: 500; text-overflow: ellipsis; max-width: 100%; @@ -43,4 +43,3 @@ .infoIcon { color: var(--euiColorMediumShade); } - diff --git a/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx b/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx index 2d4dc0d478..5a6f0e430f 100644 --- a/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx +++ b/redisinsight/ui/src/components/keys-summary/KeysSummary.tsx @@ -18,76 +18,80 @@ export interface Props { loadMoreItems?: (config: any) => void } -const KeysSummary = ({ - items, - loading, - scanned = 0, - totalItemsCount = 0, - scanMoreStyle, - loadMoreItems, -}: Props) => ( - <> - {!!totalItemsCount && ( -
- {!!totalItemsCount && ( - - {!!scanned && ( - <> - - - Results:  - {numberWithSpaces(items.length)} - {' '} - key - {items.length === 1 ? '' : 's'} - .  - - - Scanned - {' '} - {numberWithSpaces(scanned)} - {' '} - / - {' '} - {numberWithSpaces(totalItemsCount)} - {' '} - keys - - - - - - )} +const KeysSummary = (props: Props) => { + const { + items = [], + loading, + scanned = 0, + totalItemsCount = 0, + scanMoreStyle, + loadMoreItems, + } = props + + const resultsLength = items.length + const scannedDisplay = resultsLength > scanned ? resultsLength : scanned - {!scanned && ( - - - Total:  - {numberWithSpaces(totalItemsCount)} - - - )} + return ( + <> + {!!totalItemsCount && ( +
+ + {!!scanned && ( + <> + + + Results:  + {numberWithSpaces(resultsLength)} + {' '} + key + {resultsLength !== 1 && 's'} + .  + + + Scanned + {' '} + {numberWithSpaces(scannedDisplay)} + {' '} + / + {' '} + {numberWithSpaces(totalItemsCount)} + {' '} + keys + + + + + + )} + {!scanned && ( + + + Total:  + {numberWithSpaces(totalItemsCount)} + + + )} + +
+ )} + {loading && !totalItemsCount && ( + + Scanning... - )} -
- )} - {loading && !totalItemsCount && ( - - Scanning... - - )} - -) + )} + + ) +} export default KeysSummary diff --git a/redisinsight/ui/src/components/main-router/components/RedisStackRoutes.tsx b/redisinsight/ui/src/components/main-router/components/RedisStackRoutes.tsx index 08c80928c3..208073d72b 100644 --- a/redisinsight/ui/src/components/main-router/components/RedisStackRoutes.tsx +++ b/redisinsight/ui/src/components/main-router/components/RedisStackRoutes.tsx @@ -6,7 +6,7 @@ import { checkConnectToInstanceAction, resetConnectedInstance, setConnectedInstanceId -} from 'uiSrc/slices/instances' +} from 'uiSrc/slices/instances/instances' import RouteWithSubRoutes from 'uiSrc/utils/routerWithSubRoutes' import { Pages } from 'uiSrc/constants' import { PagePlaceholder } from 'uiSrc/components' diff --git a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts index 5534613fc5..4b27990a5c 100644 --- a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts @@ -10,6 +10,7 @@ import { RedisClusterDatabasesPage, } from 'uiSrc/pages' import WorkbenchPage from 'uiSrc/pages/workbench' +import SlowLogPage from 'uiSrc/pages/slowLog' import COMMON_ROUTES from './commonRoutes' @@ -24,6 +25,11 @@ const INSTANCE_ROUTES: IRoute[] = [ path: Pages.workbench(':instanceId'), component: WorkbenchPage, }, + { + pageName: PageNames.slowLog, + path: Pages.slowLog(':instanceId'), + component: SlowLogPage, + }, ] const ROUTES: IRoute[] = [ diff --git a/redisinsight/ui/src/components/monitor-config/MonitorConfig.spec.tsx b/redisinsight/ui/src/components/monitor-config/MonitorConfig.spec.tsx index a7382bfab4..5d0f8cc246 100644 --- a/redisinsight/ui/src/components/monitor-config/MonitorConfig.spec.tsx +++ b/redisinsight/ui/src/components/monitor-config/MonitorConfig.spec.tsx @@ -36,8 +36,8 @@ jest.mock('uiSrc/slices/cli/monitor', () => ({ }), })) -jest.mock('uiSrc/slices/instances', () => ({ - ...jest.requireActual('uiSrc/slices/instances'), +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), connectedInstanceSelector: jest.fn().mockReturnValue({ id: '1' }), diff --git a/redisinsight/ui/src/components/monitor-config/MonitorConfig.tsx b/redisinsight/ui/src/components/monitor-config/MonitorConfig.tsx index 005c5f4228..0cd8ffc52a 100644 --- a/redisinsight/ui/src/components/monitor-config/MonitorConfig.tsx +++ b/redisinsight/ui/src/components/monitor-config/MonitorConfig.tsx @@ -19,7 +19,7 @@ import { import { getBaseApiUrl } from 'uiSrc/utils' import { MonitorErrorMessages, MonitorEvent, SocketErrors, SocketEvent } from 'uiSrc/constants' import { IMonitorDataPayload } from 'uiSrc/slices/interfaces' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { IMonitorData } from 'apiSrc/modules/profiler/interfaces/monitor-data.interface' import ApiStatusCode from '../../constants/apiStatusCode' diff --git a/redisinsight/ui/src/components/monitor/Monitor/Monitor.tsx b/redisinsight/ui/src/components/monitor/Monitor/Monitor.tsx index 03cef23227..2bed5b6fe0 100644 --- a/redisinsight/ui/src/components/monitor/Monitor/Monitor.tsx +++ b/redisinsight/ui/src/components/monitor/Monitor/Monitor.tsx @@ -81,17 +81,19 @@ const Monitor = (props: Props) => { -
+
Save Log} checked={saveLogValue} onChange={(e) => setSaveLogValue(e.target.checked)} + data-testid="save-log-switch" />
diff --git a/redisinsight/ui/src/components/monitor/MonitorLog/MonitorLog.tsx b/redisinsight/ui/src/components/monitor/MonitorLog/MonitorLog.tsx index 4ebb73bff3..66a10d3f8e 100644 --- a/redisinsight/ui/src/components/monitor/MonitorLog/MonitorLog.tsx +++ b/redisinsight/ui/src/components/monitor/MonitorLog/MonitorLog.tsx @@ -52,8 +52,9 @@ const MonitorLog = () => {
- + {format(timestamp.start, 'hh:mm:ss')}  –  diff --git a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx index becc88f7a0..c0c067ea1a 100644 --- a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx +++ b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx @@ -51,8 +51,8 @@ describe('NavigationMenu', () => { describe('with connectedInstance', () => { beforeAll(() => { - jest.mock('uiSrc/slices/instances', () => ({ - ...jest.requireActual('uiSrc/slices/instances'), + jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), connectedInstanceSelector: jest.fn().mockReturnValue({ id: '123', connectionType: 'STANDALONE', diff --git a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx index 49609ac9d9..e0a7dd85d2 100644 --- a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx +++ b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx @@ -21,7 +21,7 @@ import { import { PageNames, Pages } from 'uiSrc/constants' import { EXTERNAL_LINKS } from 'uiSrc/constants/links' import { getRouterLinkProps } from 'uiSrc/services' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { appElectronInfoSelector, setReleaseNotesViewed, setShortcutsFlyoutState } from 'uiSrc/slices/app/info' import LogoSVG from 'uiSrc/assets/img/logo.svg' import SettingsSVG from 'uiSrc/assets/img/sidebar/settings.svg' @@ -30,6 +30,8 @@ import BrowserSVG from 'uiSrc/assets/img/sidebar/browser.svg' import BrowserActiveSVG from 'uiSrc/assets/img/sidebar/browser_active.svg' import WorkbenchSVG from 'uiSrc/assets/img/sidebar/workbench.svg' import WorkbenchActiveSVG from 'uiSrc/assets/img/sidebar/workbench_active.svg' +import SlowLogSVG from 'uiSrc/assets/img/sidebar/slowlog.svg' +import SlowLogActiveSVG from 'uiSrc/assets/img/sidebar/slowlog_active.svg' import GithubSVG from 'uiSrc/assets/img/sidebar/github.svg' import Divider from 'uiSrc/components/divider/Divider' @@ -38,6 +40,7 @@ import styles from './styles.module.scss' const workbenchPath = `/${PageNames.workbench}` const browserPath = `/${PageNames.browser}` +const slowLogPath = `/${PageNames.slowLog}` interface INavigations { isActivePage: boolean; @@ -78,6 +81,9 @@ const NavigationMenu = ({ buildType }: IProps) => { const handleGoBrowserPage = () => { history.push(Pages.browser(connectedInstanceId)) } + const handleGoSlowLogPage = () => { + history.push(Pages.slowLog(connectedInstanceId)) + } const onKeyboardShortcutClick = () => { setIsHelpMenuActive(false) @@ -113,6 +119,20 @@ const NavigationMenu = ({ buildType }: IProps) => { return this.isActivePage ? WorkbenchSVG : WorkbenchActiveSVG }, }, + { + tooltipText: 'Slow Log', + ariaLabel: 'SlowLog page button', + onClick: handleGoSlowLogPage, + dataTestId: 'slowlog-page-btn', + connectedInstanceId, + isActivePage: activePage === slowLogPath, + getClassName() { + return cx(styles.navigationButton, { [styles.active]: this.isActivePage }) + }, + getIconType() { + return this.isActivePage ? SlowLogActiveSVG : SlowLogSVG + }, + }, ] const publicRoutes: INavigations[] = [ diff --git a/redisinsight/ui/src/components/notifications/success-messages.tsx b/redisinsight/ui/src/components/notifications/success-messages.tsx index e9fffb5161..e081845b29 100644 --- a/redisinsight/ui/src/components/notifications/success-messages.tsx +++ b/redisinsight/ui/src/components/notifications/success-messages.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { EXTERNAL_LINKS } from 'uiSrc/constants/links' import { formatNameShort, Maybe } from 'uiSrc/utils' import styles from './styles.module.scss' @@ -108,10 +109,14 @@ export default { ), } }, - INSTALLED_NEW_UPDATE: (updateDownloadedVersion: string) => ({ + INSTALLED_NEW_UPDATE: (updateDownloadedVersion: string, onClickLink?: () => void) => ({ title: 'Application updated', - message: `Your application has been updated to ${updateDownloadedVersion}. Find more - information in Release Notes.`, + message: ( + <> + {`Your application has been updated to ${updateDownloadedVersion}. Find more information in `} + onClickLink?.()} className="link-underline" target="_blank" rel="noreferrer">Release Notes. + + ), group: 'upgrade' }), } diff --git a/redisinsight/ui/src/components/page-header/PageHeader.tsx b/redisinsight/ui/src/components/page-header/PageHeader.tsx index 55bd303581..be400cb922 100644 --- a/redisinsight/ui/src/components/page-header/PageHeader.tsx +++ b/redisinsight/ui/src/components/page-header/PageHeader.tsx @@ -5,10 +5,10 @@ import { useDispatch } from 'react-redux' import { useHistory } from 'react-router-dom' import { Theme, Pages } from 'uiSrc/constants' -import { resetDataRedisCloud } from 'uiSrc/slices/cloud' +import { resetDataRedisCloud } from 'uiSrc/slices/instances/cloud' import { ThemeContext } from 'uiSrc/contexts/themeContext' -import { resetDataRedisCluster } from 'uiSrc/slices/cluster' -import { resetDataSentinel } from 'uiSrc/slices/sentinel' +import { resetDataRedisCluster } from 'uiSrc/slices/instances/cluster' +import { resetDataSentinel } from 'uiSrc/slices/instances/sentinel' import darkLogo from 'uiSrc/assets/img/dark_logo.svg' import lightLogo from 'uiSrc/assets/img/light_logo.svg' diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx b/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx index cc6ca1a552..fad7c1c39a 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx @@ -15,7 +15,7 @@ import { sendPluginCommandAction, setPluginStateAction } from 'uiSrc/slices/app/plugins' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { appServerInfoSelector } from 'uiSrc/slices/app/info' import styles from './styles.module.scss' diff --git a/redisinsight/ui/src/components/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx b/redisinsight/ui/src/components/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx index fa2eb64880..e79e0b335d 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx @@ -14,7 +14,7 @@ import { RedisDefaultModules } from 'uiSrc/slices/interfaces' import { RSNotLoadedContent } from 'uiSrc/pages/workbench/constants' import { cliSettingsSelector } from 'uiSrc/slices/cli/cli-settings' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import ModuleNotLoaded from 'uiSrc/pages/workbench/components/module-not-loaded' const CommonErrorResponse = (command = '', result?: any) => { diff --git a/redisinsight/ui/src/components/range-filter/RangeFilter.spec.tsx b/redisinsight/ui/src/components/range-filter/RangeFilter.spec.tsx new file mode 100644 index 0000000000..b8452159f0 --- /dev/null +++ b/redisinsight/ui/src/components/range-filter/RangeFilter.spec.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import RangeFilter, { Props } from './RangeFilter' + +const mockedProps = mock() + +const startRangeTestId = 'range-start-input' +const endRangeTestId = 'range-end-input' +const resetBtnTestId = 'range-filter-btn' + +describe('RangeFilter', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call handleChangeStart onChange start range thumb', () => { + const handleChangeStart = jest.fn() + render( + + ) + const startRangeInput = screen.getByTestId(startRangeTestId) + + fireEvent.mouseUp( + startRangeInput, + { target: { value: 123 } } + ) + expect(handleChangeStart).toBeCalledTimes(1) + }) + + it('should call handleChangeEnd onChange end range thumb', () => { + const handleChangeEnd = jest.fn() + render( + + ) + const endRangeInput = screen.getByTestId(endRangeTestId) + + fireEvent.mouseUp( + endRangeInput, + { target: { value: 15 } } + ) + expect(handleChangeEnd).toBeCalledTimes(1) + }) + it('should call handleResetFilter onClick reset button', () => { + const handleResetFilter = jest.fn() + + render( + + ) + const resetBtn = screen.getByTestId(resetBtnTestId) + + fireEvent.click(resetBtn) + + expect(handleResetFilter).toBeCalledTimes(1) + }) +}) diff --git a/redisinsight/ui/src/components/range-filter/RangeFilter.tsx b/redisinsight/ui/src/components/range-filter/RangeFilter.tsx new file mode 100644 index 0000000000..f8503419e4 --- /dev/null +++ b/redisinsight/ui/src/components/range-filter/RangeFilter.tsx @@ -0,0 +1,209 @@ +import React, { useCallback, useState, useEffect, useRef } from 'react' +import cx from 'classnames' + +import { getFormatTime, } from 'uiSrc/utils/streamUtils' + +import styles from './styles.module.scss' + +const buttonString = 'Reset Filter' + +export interface Props { + max: number + min: number + start: number + end: number + handleChangeStart: (value: number, shouldSentEventTelemetry: boolean) => void + handleChangeEnd: (value: number, shouldSentEventTelemetry: boolean) => void + handleUpdateRangeMax: (value: number) => void + handleUpdateRangeMin: (value: number) => void + handleResetFilter: () => void +} + +function usePrevious(value: any) { + const ref = useRef() + useEffect(() => { + ref.current = value + }) + return ref.current +} + +const RangeFilter = (props: Props) => { + const { + max, + min, + start, + end, + handleChangeStart, + handleChangeEnd, + handleUpdateRangeMax, + handleUpdateRangeMin, + handleResetFilter + } = props + + const [startVal, setStartVal] = useState(start) + const [endVal, setEndVal] = useState(end) + + const getPercent = useCallback( + (value) => Math.round(((value - min) / (max - min)) * 100), + [min, max] + ) + + const minValRef = useRef(null) + const maxValRef = useRef(null) + const range = useRef(null) + + const prevValue = usePrevious({ max, min }) ?? { max: 0, min: 0 } + + const onChangeStart = useCallback( + ({ target: { value } }) => { + const newValue = Math.min(+value, endVal - 1) + setStartVal(newValue) + }, + [endVal] + ) + + const onMouseUpStart = useCallback( + ({ target: { value } }) => { + handleChangeStart(value, true) + }, + [] + ) + + const onMouseUpEnd = useCallback( + ({ target: { value } }) => { + handleChangeEnd(value, true) + }, + [] + ) + + const onChangeEnd = useCallback( + ({ target: { value } }) => { + const newValue = Math.max(+value, startVal + 1) + setEndVal(newValue) + }, + [startVal] + ) + + useEffect(() => { + if (maxValRef.current) { + const minPercent = getPercent(startVal) + const maxPercent = getPercent(+maxValRef.current.value) + + if (range.current) { + range.current.style.left = `${minPercent}%` + range.current.style.width = `${maxPercent - minPercent}%` + } + } + }, [startVal, getPercent]) + + useEffect(() => { + if (minValRef.current) { + const minPercent = getPercent(+minValRef.current.value) + const maxPercent = getPercent(endVal) + + if (range.current) { + range.current.style.width = `${maxPercent - minPercent}%` + } + } + }, [endVal, getPercent]) + + useEffect(() => { + setStartVal(start) + }, [start]) + + useEffect(() => { + setEndVal(end) + }, [end]) + + useEffect(() => { + if (prevValue.max !== max && end === prevValue.max) { + handleUpdateRangeMax(max) + } + if (prevValue.min !== min && start === prevValue.min) { + handleUpdateRangeMin(min) + } + }, [prevValue]) + + if (start === 0 && max !== 0 && end === 0 && min !== 0) { + return ( +
+
+
+ ) + } + + if (start === end) { + return ( +
+
+
{getFormatTime(start?.toString())}
+
{getFormatTime(end?.toString())}
+
+
+ ) + } + + return ( + <> +
+ + +
+
+
+
+ {getFormatTime(startVal?.toString())} +
+
(max - min) / 2 + }) + } + > + {getFormatTime(endVal?.toString())} +
+
+
+
+ {(start !== min || end !== max) && ( + + )} + + ) +} + +export default RangeFilter diff --git a/redisinsight/ui/src/components/range-filter/styles.module.scss b/redisinsight/ui/src/components/range-filter/styles.module.scss new file mode 100644 index 0000000000..88babe2ced --- /dev/null +++ b/redisinsight/ui/src/components/range-filter/styles.module.scss @@ -0,0 +1,187 @@ +.rangeWrapper { + margin: 30px 30px 26px; + padding: 12px 0; +} + +.resetButton { + position: absolute; + right: 30px; + top: 80px; + z-index: 10; + text-decoration: underline; + color: var(--euiTextSubduedColor); + &:hover, + &:focus { + color: var(--euiTextColor); + } + font: normal normal 500 13px/18px Graphik, sans-serif; +} + +.slider { + position: relative; + width: 100%; +} + +.sliderTrack, +.sliderRange, +.sliderLeftValue, +.sliderRightValue { + position: absolute; +} + +.sliderTrack { + background-color: var(--separatorColor); + width: 100%; + height: 1px; + margin-top: 2px; + z-index: 1; +} + +.rangeWrapper .sliderRange { + height: 1px; + background-color: var(--euiColorPrimary); + z-index: 2; + transform: translateY(2px); + + &:before { + content: ''; + width: 1px; + height: 6px; + position: absolute; + top: -5px; + left: -1px; + background-color: var(--euiColorPrimary); + } + + &:after { + content: ''; + width: 1px; + height: 6px; + position: absolute; + right: -1px; + background-color: var(--euiColorPrimary); + } +} + +.rangeWrapper:hover .sliderRange { + height: 5px; + transform: translateY(0px); + + &:before { + + width: 2px; + height: 12px; + top: -7px; + } + + &:after { + width: 2px; + height: 12px; + } +} + +.sliderLeftValue, +.sliderRightValue { + width: max-content; + color: var(--euiColorMediumShade); + font: normal normal normal 12px/18px Graphik; + margin-top: 8px; +} + +.sliderLeftValue { + left: 0; + margin-top: -25px; +} + +.rangeWrapper:hover .sliderLeftValue { + margin-top: -23px; +} + +.rangeWrapper:hover .sliderRightValue { + margin-top: 10px; +} + +.sliderLeftValue.leftPosition { + transform: translateX(-100%); +} + +.sliderRightValue.rightPosition { + transform: translateX(100%); +} + +.sliderRightValue { + right: -4px; +} + +.mockRange { + left: 30px; + width: calc(100% - 56px); +} + +.thumb, +.thumb::-webkit-slider-thumb { + -webkit-appearance: none; + -webkit-tap-highlight-color: transparent; +} + +.thumb { + pointer-events: none; + position: absolute; + height: 0; + width: calc(100% - 60px); + outline: none; +} + +@-moz-document url-prefix() { + .thumb { + margin-top: 0.33px; + } +} + +.thumbZindex3 { + z-index: 3; +} + +.thumbZindex4 { + z-index: 4; +} + +.thumb::-moz-range-thumb { + width: 24px; + height: 24px; + border: none; + border-radius: 0; + cursor: ew-resize; + margin-top: 4px; + pointer-events: all; + position: relative; + background: transparent; +} + +.thumbZindex3::-moz-range-thumb { + transform: translateY(-4px); +} + +.thumbZindex4::-moz-range-thumb { + transform: translateY(8px) rotate(180deg); +} + +input[type='range']::-webkit-slider-thumb { + width: 24px; + height: 24px; + border: none; + border-radius: 0; + cursor: ew-resize; + margin-top: 4px; + pointer-events: all; + position: relative; + background: transparent; +} + +input[type='range']:first-child::-webkit-slider-thumb { + transform: translateY(-4px); +} + +input[type='range']:last-of-type::-webkit-slider-thumb { + transform: translateY(8px) rotate(180deg); +} diff --git a/redisinsight/ui/src/components/table-column-search-trigger/TableColumnSearchTrigger.tsx b/redisinsight/ui/src/components/table-column-search-trigger/TableColumnSearchTrigger.tsx index 13999cfcfc..ce45284ab3 100644 --- a/redisinsight/ui/src/components/table-column-search-trigger/TableColumnSearchTrigger.tsx +++ b/redisinsight/ui/src/components/table-column-search-trigger/TableColumnSearchTrigger.tsx @@ -73,7 +73,7 @@ const TableColumnSearchTrigger = (props: Props) => { } return ( -
+
{ const { + selectable = true, headerHeight = 44, rowHeight = 40, scanned = 0, @@ -42,6 +43,8 @@ const VirtualTable = (props: IProps) => { setScrollTopPosition = () => {}, scrollTopProp = 0, hideFooter = false, + tableWidth = 0, + hideProgress, } = props const scrollTopRef = useRef(0) const [selectedRowIndex, setSelectedRowIndex] = useState>(null) @@ -89,7 +92,7 @@ const VirtualTable = (props: IProps) => { const isRowSelectable = checkIfRowSelectable(data.rowData) onRowClick(data) - if (isRowSelectable) { + if (isRowSelectable && selectable) { setSelectedRowIndex(data.index) } } @@ -139,7 +142,7 @@ const VirtualTable = (props: IProps) => { ) } - const headerRenderer = ({ columnIndex }: any) => { + const headerRenderer = ({ columnIndex, cellClass = '' }: any) => { const column = columns[columnIndex] const isColumnSorted = sortedColumn && sortedColumn.column === column.id @@ -151,18 +154,19 @@ const VirtualTable = (props: IProps) => { type="button" onClick={() => changeSorting(column.id)} className={cx( + cellClass, styles.headerButton, isColumnSorted ? styles.headerButtonSorted : null, )} data-testid="score-button" style={{ justifyContent: column.alignment }} > - {column.label} + {column.label}
)} {(!column.isSortable || (column.isSortable && searching)) && ( -
+
{ flex: '1', }} > - {column.label} + {column.label}
{column.isSearchable && searchRenderer(column)}
@@ -288,7 +292,7 @@ const VirtualTable = (props: IProps) => { onWheel={onWheel} data-testid="virtual-table-container" > - {loading ? ( + {loading && !hideProgress ? ( { onRowsRendered={onRowsRendered} headerHeight={headerHeight} rowHeight={rowHeight} - width={width} + width={tableWidth > width ? tableWidth : width} noRowsRenderer={noRowsRenderer} height={height} className={styles.table} - gridClassName={cx(styles.customScroll, { + gridClassName={cx(styles.customScroll, styles.grid, { [`${styles.disableScroll}`]: disableScroll, })} rowClassName={({ index }) => @@ -349,6 +353,7 @@ const VirtualTable = (props: IProps) => { headerRenderer({ ...headerProps, columnIndex: index, + cellClass: column.headerCellClassName, })} cellRenderer={cellRenderer} headerClassName={column.headerClassName ?? ''} diff --git a/redisinsight/ui/src/components/virtual-table/interfaces.ts b/redisinsight/ui/src/components/virtual-table/interfaces.ts index f9f422ea36..2ba949593d 100644 --- a/redisinsight/ui/src/components/virtual-table/interfaces.ts +++ b/redisinsight/ui/src/components/virtual-table/interfaces.ts @@ -30,6 +30,7 @@ export interface ITableColumn { isSearchOpen?: boolean initialSearchValue?: string headerClassName?: string + headerCellClassName?: string truncateText?: boolean relativeWidth?: number absoluteWidth?: number | string @@ -49,7 +50,7 @@ export interface IProps { loadMoreItems?: (config: any) => void rowHeight?: number footerHeight?: number - isRowSelectable?: boolean + selectable?: boolean keyName?: string headerHeight?: number searching?: boolean @@ -66,6 +67,8 @@ export interface IProps { setScrollTopPosition?: (position: number) => void scrollTopProp?: number hideFooter?: boolean + tableWidth?: number + hideProgress?: boolean } export interface ISortedColumn { diff --git a/redisinsight/ui/src/components/virtual-table/styles.module.scss b/redisinsight/ui/src/components/virtual-table/styles.module.scss index 57a55b3bf2..4ca2f73678 100644 --- a/redisinsight/ui/src/components/virtual-table/styles.module.scss +++ b/redisinsight/ui/src/components/virtual-table/styles.module.scss @@ -1,6 +1,6 @@ -@import '@elastic/eui/src/global_styling/mixins/helpers'; -@import '@elastic/eui/src/components/table/mixins'; -@import '@elastic/eui/src/global_styling/index'; +@import "@elastic/eui/src/global_styling/mixins/helpers"; +@import "@elastic/eui/src/components/table/mixins"; +@import "@elastic/eui/src/global_styling/index"; $headerHeight: 44px; $footerHeight: 38px; @@ -9,6 +9,10 @@ $footerHeight: 38px; @include euiScrollBar; } +.grid { + overflow: auto !important; +} + .disableScroll { overflow-y: hidden !important; } @@ -29,6 +33,10 @@ $footerHeight: 38px; } .table { + @include euiScrollBar; + overflow-x: auto; + overflow-y: hidden; + :global { .ReactVirtualized__Table__headerRow { cursor: initial !important; @@ -64,6 +72,10 @@ $footerHeight: 38px; .tableRowEven { background: var(--browserTableRowEven); + + :global(.stream-entry-actions) { + background-color: var(--browserTableRowEven) !important; + } } :global(.table-row-selected) { @@ -139,7 +151,6 @@ $footerHeight: 38px; white-space: pre-wrap; } - :global(.key-details-table) { :global(.ReactVirtualized__Table__row) { font-size: 13px; @@ -150,9 +161,9 @@ $footerHeight: 38px; height: calc(100% - 58px); } - :global(.key-details-table) { - height: calc(100% - 38px); + height: calc(100% - 94px); + position: relative; &:global(.footerOpened) { :global(.ReactVirtualized__Table__Grid) { padding-bottom: 254px; @@ -186,7 +197,7 @@ $footerHeight: 38px; } .headerCell { - padding: 18px 20px; + padding: 18px 4px 18px 20px; } .tableRowCell { @@ -232,26 +243,25 @@ $footerHeight: 38px; } .loading:after { - content: ' .'; + content: " ."; animation: dots 1s steps(5, end) infinite; } @keyframes dots { - 0%, 20% { - color: rgba(0,0,0,0); - text-shadow: - .25em 0 0 rgba(0,0,0,0), - .5em 0 0 rgba(0,0,0,0);} + 0%, + 20% { + color: rgba(0, 0, 0, 0); + text-shadow: 0.25em 0 0 rgba(0, 0, 0, 0), 0.5em 0 0 rgba(0, 0, 0, 0); + } 40% { color: white; - text-shadow: - .25em 0 0 rgba(0,0,0,0), - .5em 0 0 rgba(0,0,0,0);} + text-shadow: 0.25em 0 0 rgba(0, 0, 0, 0), 0.5em 0 0 rgba(0, 0, 0, 0); + } 60% { - text-shadow: - .25em 0 0 white, - .5em 0 0 rgba(0,0,0,0);} - 80%, 100% { - text-shadow: - .25em 0 0 white, - .5em 0 0 white;}} + text-shadow: 0.25em 0 0 white, 0.5em 0 0 rgba(0, 0, 0, 0); + } + 80%, + 100% { + text-shadow: 0.25em 0 0 white, 0.5em 0 0 white; + } +} diff --git a/redisinsight/ui/src/components/virtual-tree/VirtualTree.tsx b/redisinsight/ui/src/components/virtual-tree/VirtualTree.tsx index 262e0c5d7e..82aff68f48 100644 --- a/redisinsight/ui/src/components/virtual-tree/VirtualTree.tsx +++ b/redisinsight/ui/src/components/virtual-tree/VirtualTree.tsx @@ -80,7 +80,7 @@ const VirtualTree = (props: Props) => { // select "root" Keys after render a new tree (construct a tree) useEffect(() => { - if (nodes.length === 0 || !selectDefaultLeaf) { + if (nodes.length === 0 || !selectDefaultLeaf || loading) { return } @@ -90,7 +90,7 @@ const VirtualTree = (props: Props) => { onStatusSelected?.(rootLeaf?.fullName ?? '', rootLeaf?.keys) onSelectLeaf?.(rootLeaf?.keys ?? []) } - }, [nodes, selectDefaultLeaf]) + }, [nodes, loading, selectDefaultLeaf]) useEffect(() => { if (!items?.length) { diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index 9d03acb4d3..eef3fb81be 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -33,6 +33,9 @@ enum ApiEndpoints { REJSON_GET = 'rejson-rl/get', REJSON_SET = 'rejson-rl/set', REJSON_ARRAPPEND = 'rejson-rl/arrappend', + STREAMS_ENTRIES = 'streams/entries', + STREAMS_ENTRIES_GET = 'streams/entries/get', + STREAMS = 'streams', CLI = 'cli', CLI_BLOCKING_COMMANDS = 'info/cli-blocking-commands', CLI_UNSUPPORTED_COMMANDS = 'info/cli-unsupported-commands', @@ -55,6 +58,9 @@ enum ApiEndpoints { CONTENT_CREATE_DATABASE = 'static/content/create-redis.json', GUIDES_PATH = 'static/guides', TUTORIALS_PATH = 'static/tutorials', + + SLOW_LOGS = 'slow-logs', + SLOW_LOGS_CONFIG = 'slow-logs/config', } export const DEFAULT_SEARCH_MATCH = '*' @@ -64,5 +70,7 @@ const SCAN_TREE_COUNT_DEFAULT_ENV = process.env.SCAN_TREE_COUNT_DEFAULT || '1000 export const SCAN_COUNT_DEFAULT = parseInt(SCAN_COUNT_DEFAULT_ENV, 10) export const SCAN_TREE_COUNT_DEFAULT = parseInt(SCAN_TREE_COUNT_DEFAULT_ENV, 10) +export const SCAN_STREAM_START_DEFAULT = '-' +export const SCAN_STREAM_END_DEFAULT = '+' export default ApiEndpoints diff --git a/redisinsight/ui/src/constants/durationUnits.tsx b/redisinsight/ui/src/constants/durationUnits.tsx new file mode 100644 index 0000000000..924fee3c24 --- /dev/null +++ b/redisinsight/ui/src/constants/durationUnits.tsx @@ -0,0 +1,24 @@ +import { EuiSuperSelectOption } from '@elastic/eui' + +export enum DurationUnits { + microSeconds = 'µs', + milliSeconds = 'ms' +} + +export const DURATION_UNITS: EuiSuperSelectOption[] = [ + { + inputDisplay: DurationUnits.microSeconds, + value: DurationUnits.microSeconds, + }, + { + inputDisplay: DurationUnits.milliSeconds, + value: DurationUnits.milliSeconds, + }, +] + +export const MINUS_ONE = -1 +export const DEFAULT_SLOWLOG_MAX_LEN = 128 +export const DEFAULT_SLOWLOG_SLOWER_THAN = 10_000 +export const DEFAULT_SLOWLOG_DURATION_UNIT = DurationUnits.microSeconds + +export default DURATION_UNITS diff --git a/redisinsight/ui/src/constants/index.ts b/redisinsight/ui/src/constants/index.ts index 8297a21011..5a1c3a006b 100644 --- a/redisinsight/ui/src/constants/index.ts +++ b/redisinsight/ui/src/constants/index.ts @@ -20,4 +20,5 @@ export * from './mocks/mock-guides' export * from './mocks/mock-tutorials' export * from './socketErrors' export * from './browser' +export * from './durationUnits' export { ApiEndpoints, BrowserStorageItem, ApiStatusCode, apiErrors } diff --git a/redisinsight/ui/src/constants/keys.ts b/redisinsight/ui/src/constants/keys.ts index 3343327da8..98a946e04b 100644 --- a/redisinsight/ui/src/constants/keys.ts +++ b/redisinsight/ui/src/constants/keys.ts @@ -8,9 +8,6 @@ export enum KeyTypes { String = 'string', ReJSON = 'ReJSON-RL', JSON = 'json', -} - -export enum UnsupportedKeyTypes { Stream = 'stream', } @@ -27,7 +24,7 @@ export const GROUP_TYPES_DISPLAY = Object.freeze({ [KeyTypes.String]: 'String', [KeyTypes.ReJSON]: 'JSON', [KeyTypes.JSON]: 'JSON', - [UnsupportedKeyTypes.Stream]: 'Stream', + [KeyTypes.Stream]: 'Stream', [ModulesKeyTypes.Graph]: 'GRAPH', [ModulesKeyTypes.TimeSeries]: 'TS', [CommandGroup.Bitmap]: 'Bitmap', @@ -58,7 +55,7 @@ export const GROUP_TYPES_COLORS = Object.freeze({ [KeyTypes.String]: 'var(--typeStringColor)', [KeyTypes.ReJSON]: 'var(--typeReJSONColor)', [KeyTypes.JSON]: 'var(--typeReJSONColor)', - [UnsupportedKeyTypes.Stream]: 'var(--typeStreamColor)', + [KeyTypes.Stream]: 'var(--typeStreamColor)', [ModulesKeyTypes.Graph]: 'var(--typeGraphColor)', [ModulesKeyTypes.TimeSeries]: 'var(--typeTimeSeriesColor)', [CommandGroup.SortedSet]: 'var(--groupSortedSetColor)', @@ -74,7 +71,21 @@ export const GROUP_TYPES_COLORS = Object.freeze({ [CommandGroup.HyperLogLog]: 'var(--groupHyperLolLogColor)', }) -export const KEY_TYPES_ACTIONS = Object.freeze({ +export type KeyTypesActions = { + [key: string]: { + addItems?: { + name: string + } + removeItems?: { + name: string + } + editItem?: { + name: string + } + } +} + +export const KEY_TYPES_ACTIONS: KeyTypesActions = Object.freeze({ [KeyTypes.Hash]: { addItems: { name: 'Add Fields', @@ -104,6 +115,11 @@ export const KEY_TYPES_ACTIONS = Object.freeze({ }, }, [KeyTypes.ReJSON]: {}, + [KeyTypes.Stream]: { + addItems: { + name: 'New Entry', + }, + } }) export enum SortOrder { @@ -111,13 +127,21 @@ export enum SortOrder { DESC = 'DESC', } -export const LENGTH_NAMING_BY_TYPE = Object.freeze({ +export interface LengthNamingByType { + [key: string]: string +} + +export const LENGTH_NAMING_BY_TYPE: LengthNamingByType = Object.freeze({ [ModulesKeyTypes.Graph]: 'Nodes', [ModulesKeyTypes.TimeSeries]: 'Samples', - [UnsupportedKeyTypes.Stream]: 'Entries' + [KeyTypes.Stream]: 'Entries' }) -export const MODULES_KEY_TYPES_NAMES = Object.freeze({ +export interface ModulesKeyTypesNames { + [key: string]: string +} + +export const MODULES_KEY_TYPES_NAMES: ModulesKeyTypesNames = Object.freeze({ [ModulesKeyTypes.Graph]: 'RedisGraph', [ModulesKeyTypes.TimeSeries]: 'RedisTimeSeries', }) diff --git a/redisinsight/ui/src/constants/links.ts b/redisinsight/ui/src/constants/links.ts index 7301d87f39..0c29f63633 100644 --- a/redisinsight/ui/src/constants/links.ts +++ b/redisinsight/ui/src/constants/links.ts @@ -1,5 +1,5 @@ export const EXTERNAL_LINKS = { githubRepo: 'https://github.com/RedisInsight/RedisInsight', githubIssues: 'https://github.com/RedisInsight/RedisInsight/issues', - releaseNotes: 'https://docs.redis.com/staging/release-ri-v2.0/ri/release-notes/', + releaseNotes: 'https://github.com/RedisInsight/RedisInsight/releases', } diff --git a/redisinsight/ui/src/constants/pages.ts b/redisinsight/ui/src/constants/pages.ts index 47899df8ee..946b8b1767 100644 --- a/redisinsight/ui/src/constants/pages.ts +++ b/redisinsight/ui/src/constants/pages.ts @@ -12,7 +12,8 @@ export interface IRoute { export enum PageNames { workbench = 'workbench', - browser = 'browser' + browser = 'browser', + slowLog = 'slowlog' } const redisCloud = '/redis-cloud' @@ -31,5 +32,6 @@ export const Pages = { sentinelDatabases: `${sentinel}/databases`, sentinelDatabasesResult: `${sentinel}/databases-result`, browser: (instanceId: string) => `/${instanceId}/${PageNames.browser}`, - workbench: (instanceId: string) => `/${instanceId}/${PageNames.workbench}` + workbench: (instanceId: string) => `/${instanceId}/${PageNames.workbench}`, + slowLog: (instanceId: string) => `/${instanceId}/${PageNames.slowLog}`, } diff --git a/redisinsight/ui/src/constants/storage.ts b/redisinsight/ui/src/constants/storage.ts index 0eb591cdf0..85eab3da0b 100644 --- a/redisinsight/ui/src/constants/storage.ts +++ b/redisinsight/ui/src/constants/storage.ts @@ -11,6 +11,12 @@ enum BrowserStorageItem { wbInputHistory = 'wbInputHistory', isEnablementAreaMinimized = 'isEnablementAreaMinimized', treeViewDelimiter = 'treeViewDelimiter', + autoRefreshRate = 'autoRefreshRate', + dbConfig = 'dbConfig_' } export default BrowserStorageItem + +export enum ConfigDBStorageItem { + slowLogDurationUnit = 'slowLogDurationUnit' +} diff --git a/redisinsight/ui/src/electron/utils/ipcCheckUpdates.ts b/redisinsight/ui/src/electron/utils/ipcCheckUpdates.ts index c45057e50d..ac16438d81 100644 --- a/redisinsight/ui/src/electron/utils/ipcCheckUpdates.ts +++ b/redisinsight/ui/src/electron/utils/ipcCheckUpdates.ts @@ -22,7 +22,9 @@ export const ipcCheckUpdates = async (serverInfo: GetServerInfoResponse, dispatc if (isUpdateDownloaded && !isUpdateAvailable) { if (serverInfo.appVersion === updateDownloadedVersion) { - dispatch(addMessageNotification(successMessages.INSTALLED_NEW_UPDATE(updateDownloadedVersion))) + dispatch(addMessageNotification( + successMessages.INSTALLED_NEW_UPDATE(updateDownloadedVersion, () => dispatch(setReleaseNotesViewed(true))) + )) } await ipcRenderer.invoke(IpcEvent.deleteStoreValue, ElectronStorageItem.updateDownloaded) diff --git a/redisinsight/ui/src/packages/redisearch/package.json b/redisinsight/ui/src/packages/redisearch/package.json index 8973963eab..3cee132061 100644 --- a/redisinsight/ui/src/packages/redisearch/package.json +++ b/redisinsight/ui/src/packages/redisearch/package.json @@ -21,7 +21,7 @@ "build:css:dark": "parcel build src/styles/dark_theme.scss --no-source-maps --no-cache --dist-dir dist", "build:css:light": "parcel build src/styles/light_theme.scss --no-source-maps --no-cache --dist-dir dist", "build:assets": "parcel build src/assets/**/* --dist-dir dist", - "minify:js": "terser --compress --mangle -- dist/main.js > dist/index.js && rimraf dist/main.js" + "minify:js": "terser --compress -- dist/main.js > dist/index.js && rimraf dist/main.js" }, "targets": { "main": false, diff --git a/redisinsight/ui/src/packages/redisearch/src/icons/arrow_down.js b/redisinsight/ui/src/packages/redisearch/src/icons/arrow_down.js index f2ce918a8f..ab2e2bff28 100644 --- a/redisinsight/ui/src/packages/redisearch/src/icons/arrow_down.js +++ b/redisinsight/ui/src/packages/redisearch/src/icons/arrow_down.js @@ -11,22 +11,26 @@ var EuiIconArrowDown = function EuiIconArrowDown(_ref) { titleId = _ref.titleId, props = _objectWithoutProperties(_ref, ["title", "titleId"]); - // For e2e tests. TestCafe is failing for default icons - if(process.env.E2E) { + // For e2e tests. Hammerhead cannot create svg throw createElementNS + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /*#__PURE__*/React.createElement("svg", _extends({ + width: 16, + height: 16, + viewBox: "0 0 16 16", + xmlns: "http://www.w3.org/2000/svg", + "aria-labelledby": titleId + }, props), title ? /*#__PURE__*/React.createElement("title", { + id: titleId + }, title) : null, /*#__PURE__*/React.createElement("path", { + fillRule: "non-zero", + d: "M13.069 5.157L8.384 9.768a.546.546 0 01-.768 0L2.93 5.158a.552.552 0 00-.771 0 .53.53 0 000 .759l4.684 4.61c.641.631 1.672.63 2.312 0l4.684-4.61a.53.53 0 000-.76.552.552 0 00-.771 0z" + })); + } catch (e) { return } - return /*#__PURE__*/React.createElement("svg", _extends({ - width: 16, - height: 16, - viewBox: "0 0 16 16", - xmlns: "http://www.w3.org/2000/svg", - "aria-labelledby": titleId - }, props), title ? /*#__PURE__*/React.createElement("title", { - id: titleId - }, title) : null, /*#__PURE__*/React.createElement("path", { - fillRule: "non-zero", - d: "M13.069 5.157L8.384 9.768a.546.546 0 01-.768 0L2.93 5.158a.552.552 0 00-.771 0 .53.53 0 000 .759l4.684 4.61c.641.631 1.672.63 2.312 0l4.684-4.61a.53.53 0 000-.76.552.552 0 00-.771 0z" - })); + }; export var icon = EuiIconArrowDown; diff --git a/redisinsight/ui/src/packages/redisearch/src/icons/arrow_left.js b/redisinsight/ui/src/packages/redisearch/src/icons/arrow_left.js index 28341f52c9..ca9522a52a 100644 --- a/redisinsight/ui/src/packages/redisearch/src/icons/arrow_left.js +++ b/redisinsight/ui/src/packages/redisearch/src/icons/arrow_left.js @@ -11,22 +11,25 @@ var EuiIconArrowLeft = function EuiIconArrowLeft(_ref) { titleId = _ref.titleId, props = _objectWithoutProperties(_ref, ["title", "titleId"]); - // For e2e tests. TestCafe is failing for default icons - if(process.env.E2E) { + // For e2e tests. Hammerhead cannot create svg throw createElementNS + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /*#__PURE__*/React.createElement("svg", _extends({ + width: 16, + height: 16, + viewBox: "0 0 16 16", + xmlns: "http://www.w3.org/2000/svg", + "aria-labelledby": titleId + }, props), title ? /*#__PURE__*/React.createElement("title", { + id: titleId + }, title) : null, /*#__PURE__*/React.createElement("path", { + fillRule: "nonzero", + d: "M10.843 13.069L6.232 8.384a.546.546 0 010-.768l4.61-4.685a.552.552 0 000-.771.53.53 0 00-.759 0l-4.61 4.684a1.65 1.65 0 000 2.312l4.61 4.684a.53.53 0 00.76 0 .552.552 0 000-.771z" + })); + } catch (e) { return } - return /*#__PURE__*/React.createElement("svg", _extends({ - width: 16, - height: 16, - viewBox: "0 0 16 16", - xmlns: "http://www.w3.org/2000/svg", - "aria-labelledby": titleId - }, props), title ? /*#__PURE__*/React.createElement("title", { - id: titleId - }, title) : null, /*#__PURE__*/React.createElement("path", { - fillRule: "nonzero", - d: "M10.843 13.069L6.232 8.384a.546.546 0 010-.768l4.61-4.685a.552.552 0 000-.771.53.53 0 00-.759 0l-4.61 4.684a1.65 1.65 0 000 2.312l4.61 4.684a.53.53 0 00.76 0 .552.552 0 000-.771z" - })); }; export var icon = EuiIconArrowLeft; diff --git a/redisinsight/ui/src/packages/redisearch/src/icons/arrow_right.js b/redisinsight/ui/src/packages/redisearch/src/icons/arrow_right.js index fa978fd973..5263d407b8 100644 --- a/redisinsight/ui/src/packages/redisearch/src/icons/arrow_right.js +++ b/redisinsight/ui/src/packages/redisearch/src/icons/arrow_right.js @@ -17,21 +17,26 @@ const EuiIconArrowRight = function EuiIconArrowRight(_ref) { const { titleId } = _ref const props = _objectWithoutProperties(_ref, ['title', 'titleId']) - // For e2e tests. TestCafe is failing for default icons - if(process.env.E2E) { + // For e2e tests. Hammerhead cannot create svg throw createElementNS + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /* #__PURE__ */React.createElement('svg', { + width: 16, + height: 16, + viewBox: '0 0 16 16', + xmlns: 'http://www.w3.org/2000/svg', + 'aria-labelledby': titleId, + ...props + }, title ? /* #__PURE__ */React.createElement('title', { + id: titleId + }, title) : null, /* #__PURE__ */React.createElement('path', { + fillRule: 'nonzero', + d: 'M5.157 13.069l4.611-4.685a.546.546 0 000-.768L5.158 2.93a.552.552 0 010-.771.53.53 0 01.759 0l4.61 4.684c.631.641.63 1.672 0 2.312l-4.61 4.684a.53.53 0 01-.76 0 .552.552 0 010-.771z' + })) + } catch (e) { return } - return /* #__PURE__ */React.createElement('svg', { width: 16, - height: 16, - viewBox: '0 0 16 16', - xmlns: 'http://www.w3.org/2000/svg', - 'aria-labelledby': titleId, - ...props }, title ? /* #__PURE__ */React.createElement('title', { - id: titleId - }, title) : null, /* #__PURE__ */React.createElement('path', { - fillRule: 'nonzero', - d: 'M5.157 13.069l4.611-4.685a.546.546 0 000-.768L5.158 2.93a.552.552 0 010-.771.53.53 0 01.759 0l4.61 4.684c.631.641.63 1.672 0 2.312l-4.61 4.684a.53.53 0 01-.76 0 .552.552 0 010-.771z' - })) } export var icon = EuiIconArrowRight diff --git a/redisinsight/ui/src/packages/redisearch/src/icons/check.js b/redisinsight/ui/src/packages/redisearch/src/icons/check.js index bfc5c70d99..bd8023fd8f 100644 --- a/redisinsight/ui/src/packages/redisearch/src/icons/check.js +++ b/redisinsight/ui/src/packages/redisearch/src/icons/check.js @@ -12,21 +12,24 @@ var EuiIconCheck = function EuiIconCheck(_ref) { props = _objectWithoutProperties(_ref, ["title", "titleId"]); // For e2e tests. TestCafe is failing for default icons - if(process.env.E2E) { + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /*#__PURE__*/React.createElement("svg", _extends({ + width: 16, + height: 16, + viewBox: "0 0 16 16", + xmlns: "http://www.w3.org/2000/svg", + "aria-labelledby": titleId + }, props), title ? /*#__PURE__*/React.createElement("title", { + id: titleId + }, title) : null, /*#__PURE__*/React.createElement("path", { + fillRule: "evenodd", + d: "M6.5 12a.502.502 0 01-.354-.146l-4-4a.502.502 0 01.708-.708L6.5 10.793l6.646-6.647a.502.502 0 01.708.708l-7 7A.502.502 0 016.5 12" + })); + } catch (e) { return } - return /*#__PURE__*/React.createElement("svg", _extends({ - width: 16, - height: 16, - viewBox: "0 0 16 16", - xmlns: "http://www.w3.org/2000/svg", - "aria-labelledby": titleId - }, props), title ? /*#__PURE__*/React.createElement("title", { - id: titleId - }, title) : null, /*#__PURE__*/React.createElement("path", { - fillRule: "evenodd", - d: "M6.5 12a.502.502 0 01-.354-.146l-4-4a.502.502 0 01.708-.708L6.5 10.793l6.646-6.647a.502.502 0 01.708.708l-7 7A.502.502 0 016.5 12" - })); }; export var icon = EuiIconCheck; diff --git a/redisinsight/ui/src/packages/redisearch/src/icons/copy.js b/redisinsight/ui/src/packages/redisearch/src/icons/copy.js index dd5601c492..35a08cc6a0 100644 --- a/redisinsight/ui/src/packages/redisearch/src/icons/copy.js +++ b/redisinsight/ui/src/packages/redisearch/src/icons/copy.js @@ -11,23 +11,26 @@ var EuiIconCopy = function EuiIconCopy(_ref) { titleId = _ref.titleId, props = _objectWithoutProperties(_ref, ["title", "titleId"]); - // For e2e tests. TestCafe is failing for default icons - if(process.env.E2E) { - return + // For e2e tests. Hammerhead cannot create svg throw createElementNS + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /*#__PURE__*/React.createElement("svg", _extends({ + width: 16, + height: 16, + viewBox: "0 0 16 16", + xmlns: "http://www.w3.org/2000/svg", + "aria-labelledby": titleId + }, props), title ? /*#__PURE__*/React.createElement("title", { + id: titleId + }, title) : null, /*#__PURE__*/React.createElement("path", { + d: "M11.4 0c.235 0 .46.099.622.273l2.743 3c.151.162.235.378.235.602v9.25a.867.867 0 01-.857.875H3.857A.867.867 0 013 13.125V.875C3 .392 3.384 0 3.857 0H11.4zM14 4h-2.6a.4.4 0 01-.4-.4V1H4v12h10V4z" + }), /*#__PURE__*/React.createElement("path", { + d: "M3 1H2a1 1 0 00-1 1v13a1 1 0 001 1h10a1 1 0 001-1v-1h-1v1H2V2h1V1z" + })); + } catch (e) { + return } - return /*#__PURE__*/React.createElement("svg", _extends({ - width: 16, - height: 16, - viewBox: "0 0 16 16", - xmlns: "http://www.w3.org/2000/svg", - "aria-labelledby": titleId - }, props), title ? /*#__PURE__*/React.createElement("title", { - id: titleId - }, title) : null, /*#__PURE__*/React.createElement("path", { - d: "M11.4 0c.235 0 .46.099.622.273l2.743 3c.151.162.235.378.235.602v9.25a.867.867 0 01-.857.875H3.857A.867.867 0 013 13.125V.875C3 .392 3.384 0 3.857 0H11.4zM14 4h-2.6a.4.4 0 01-.4-.4V1H4v12h10V4z" - }), /*#__PURE__*/React.createElement("path", { - d: "M3 1H2a1 1 0 00-1 1v13a1 1 0 001 1h10a1 1 0 001-1v-1h-1v1H2V2h1V1z" - })); }; export var icon = EuiIconCopy; diff --git a/redisinsight/ui/src/packages/redisearch/src/icons/cross.js b/redisinsight/ui/src/packages/redisearch/src/icons/cross.js index 6917e33e87..c83a8931ec 100644 --- a/redisinsight/ui/src/packages/redisearch/src/icons/cross.js +++ b/redisinsight/ui/src/packages/redisearch/src/icons/cross.js @@ -11,21 +11,24 @@ var EuiIconCross = function EuiIconCross(_ref) { titleId = _ref.titleId, props = _objectWithoutProperties(_ref, ["title", "titleId"]); - // For e2e tests. TestCafe is failing for default icons - if(process.env.E2E) { + // For e2e tests. Hammerhead cannot create svg throw createElementNS + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /*#__PURE__*/React.createElement("svg", _extends({ + width: 16, + height: 16, + viewBox: "0 0 16 16", + xmlns: "http://www.w3.org/2000/svg", + "aria-labelledby": titleId + }, props), title ? /*#__PURE__*/React.createElement("title", { + id: titleId + }, title) : null, /*#__PURE__*/React.createElement("path", { + d: "M7.293 8L3.146 3.854a.5.5 0 11.708-.708L8 7.293l4.146-4.147a.5.5 0 01.708.708L8.707 8l4.147 4.146a.5.5 0 01-.708.708L8 8.707l-4.146 4.147a.5.5 0 01-.708-.708L7.293 8z" + })); + } catch (e) { return } - return /*#__PURE__*/React.createElement("svg", _extends({ - width: 16, - height: 16, - viewBox: "0 0 16 16", - xmlns: "http://www.w3.org/2000/svg", - "aria-labelledby": titleId - }, props), title ? /*#__PURE__*/React.createElement("title", { - id: titleId - }, title) : null, /*#__PURE__*/React.createElement("path", { - d: "M7.293 8L3.146 3.854a.5.5 0 11.708-.708L8 7.293l4.146-4.147a.5.5 0 01.708.708L8.707 8l4.147 4.146a.5.5 0 01-.708.708L8 8.707l-4.146 4.147a.5.5 0 01-.708-.708L7.293 8z" - })); }; export var icon = EuiIconCross; diff --git a/redisinsight/ui/src/packages/redisearch/src/icons/empty.js b/redisinsight/ui/src/packages/redisearch/src/icons/empty.js index b2e0763d9f..2674c4de69 100644 --- a/redisinsight/ui/src/packages/redisearch/src/icons/empty.js +++ b/redisinsight/ui/src/packages/redisearch/src/icons/empty.js @@ -12,16 +12,19 @@ var EuiIconEmpty = function EuiIconEmpty(_ref) { props = _objectWithoutProperties(_ref, ["title", "titleId"]); // For e2e tests. TestCafe is failing for default icons - if(process.env.E2E) { + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /*#__PURE__*/React.createElement("svg", _extends({ + width: 16, + height: 16, + viewBox: "0 0 16 16", + xmlns: "http://www.w3.org/2000/svg", + "aria-labelledby": titleId + }, props)); + } catch (e) { return '' } - return /*#__PURE__*/React.createElement("svg", _extends({ - width: 16, - height: 16, - viewBox: "0 0 16 16", - xmlns: "http://www.w3.org/2000/svg", - "aria-labelledby": titleId - }, props)); }; export var icon = EuiIconEmpty; diff --git a/redisinsight/ui/src/packages/redisgraph/src/icons/arrow_down.js b/redisinsight/ui/src/packages/redisgraph/src/icons/arrow_down.js index 9fc44fc582..7aa44ea808 100644 --- a/redisinsight/ui/src/packages/redisgraph/src/icons/arrow_down.js +++ b/redisinsight/ui/src/packages/redisgraph/src/icons/arrow_down.js @@ -11,18 +11,25 @@ var EuiIconArrowDown = function EuiIconArrowDown(_ref) { titleId = _ref.titleId, props = _objectWithoutProperties(_ref, ["title", "titleId"]); - return /*#__PURE__*/React.createElement("svg", _extends({ - width: 16, - height: 16, - viewBox: "0 0 16 16", - xmlns: "http://www.w3.org/2000/svg", - "aria-labelledby": titleId - }, props), title ? /*#__PURE__*/React.createElement("title", { - id: titleId - }, title) : null, /*#__PURE__*/React.createElement("path", { - fillRule: "non-zero", - d: "M13.069 5.157L8.384 9.768a.546.546 0 01-.768 0L2.93 5.158a.552.552 0 00-.771 0 .53.53 0 000 .759l4.684 4.61c.641.631 1.672.63 2.312 0l4.684-4.61a.53.53 0 000-.76.552.552 0 00-.771 0z" - })); +// For e2e tests. Hammerhead cannot create svg throw createElementNS + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /*#__PURE__*/React.createElement("svg", _extends({ + width: 16, + height: 16, + viewBox: "0 0 16 16", + xmlns: "http://www.w3.org/2000/svg", + "aria-labelledby": titleId + }, props), title ? /*#__PURE__*/React.createElement("title", { + id: titleId + }, title) : null, /*#__PURE__*/React.createElement("path", { + fillRule: "non-zero", + d: "M13.069 5.157L8.384 9.768a.546.546 0 01-.768 0L2.93 5.158a.552.552 0 00-.771 0 .53.53 0 000 .759l4.684 4.61c.641.631 1.672.63 2.312 0l4.684-4.61a.53.53 0 000-.76.552.552 0 00-.771 0z" + })); + } catch (e) { + return + } }; export var icon = EuiIconArrowDown; diff --git a/redisinsight/ui/src/packages/redisgraph/src/icons/arrow_left.js b/redisinsight/ui/src/packages/redisgraph/src/icons/arrow_left.js index 9acedc3e12..ca9522a52a 100644 --- a/redisinsight/ui/src/packages/redisgraph/src/icons/arrow_left.js +++ b/redisinsight/ui/src/packages/redisgraph/src/icons/arrow_left.js @@ -11,18 +11,25 @@ var EuiIconArrowLeft = function EuiIconArrowLeft(_ref) { titleId = _ref.titleId, props = _objectWithoutProperties(_ref, ["title", "titleId"]); - return /*#__PURE__*/React.createElement("svg", _extends({ - width: 16, - height: 16, - viewBox: "0 0 16 16", - xmlns: "http://www.w3.org/2000/svg", - "aria-labelledby": titleId - }, props), title ? /*#__PURE__*/React.createElement("title", { - id: titleId - }, title) : null, /*#__PURE__*/React.createElement("path", { - fillRule: "nonzero", - d: "M10.843 13.069L6.232 8.384a.546.546 0 010-.768l4.61-4.685a.552.552 0 000-.771.53.53 0 00-.759 0l-4.61 4.684a1.65 1.65 0 000 2.312l4.61 4.684a.53.53 0 00.76 0 .552.552 0 000-.771z" - })); + // For e2e tests. Hammerhead cannot create svg throw createElementNS + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /*#__PURE__*/React.createElement("svg", _extends({ + width: 16, + height: 16, + viewBox: "0 0 16 16", + xmlns: "http://www.w3.org/2000/svg", + "aria-labelledby": titleId + }, props), title ? /*#__PURE__*/React.createElement("title", { + id: titleId + }, title) : null, /*#__PURE__*/React.createElement("path", { + fillRule: "nonzero", + d: "M10.843 13.069L6.232 8.384a.546.546 0 010-.768l4.61-4.685a.552.552 0 000-.771.53.53 0 00-.759 0l-4.61 4.684a1.65 1.65 0 000 2.312l4.61 4.684a.53.53 0 00.76 0 .552.552 0 000-.771z" + })); + } catch (e) { + return + } }; export var icon = EuiIconArrowLeft; diff --git a/redisinsight/ui/src/packages/redisgraph/src/icons/arrow_right.js b/redisinsight/ui/src/packages/redisgraph/src/icons/arrow_right.js index 0de7006630..ece2917bb1 100644 --- a/redisinsight/ui/src/packages/redisgraph/src/icons/arrow_right.js +++ b/redisinsight/ui/src/packages/redisgraph/src/icons/arrow_right.js @@ -17,17 +17,24 @@ const EuiIconArrowRight = function EuiIconArrowRight(_ref) { const { titleId } = _ref const props = _objectWithoutProperties(_ref, ['title', 'titleId']) - return /* #__PURE__ */React.createElement('svg', { width: 16, - height: 16, - viewBox: '0 0 16 16', - xmlns: 'http://www.w3.org/2000/svg', - 'aria-labelledby': titleId, - ...props }, title ? /* #__PURE__ */React.createElement('title', { - id: titleId - }, title) : null, /* #__PURE__ */React.createElement('path', { - fillRule: 'nonzero', - d: 'M5.157 13.069l4.611-4.685a.546.546 0 000-.768L5.158 2.93a.552.552 0 010-.771.53.53 0 01.759 0l4.61 4.684c.631.641.63 1.672 0 2.312l-4.61 4.684a.53.53 0 01-.76 0 .552.552 0 010-.771z' - })) + // For e2e tests. Hammerhead cannot create svg throw createElementNS + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /* #__PURE__ */React.createElement('svg', { width: 16, + height: 16, + viewBox: '0 0 16 16', + xmlns: 'http://www.w3.org/2000/svg', + 'aria-labelledby': titleId, + ...props }, title ? /* #__PURE__ */React.createElement('title', { + id: titleId + }, title) : null, /* #__PURE__ */React.createElement('path', { + fillRule: 'nonzero', + d: 'M5.157 13.069l4.611-4.685a.546.546 0 000-.768L5.158 2.93a.552.552 0 010-.771.53.53 0 01.759 0l4.61 4.684c.631.641.63 1.672 0 2.312l-4.61 4.684a.53.53 0 01-.76 0 .552.552 0 010-.771z' + })) + } catch (e) { + return + } } export var icon = EuiIconArrowRight diff --git a/redisinsight/ui/src/packages/redisgraph/src/icons/check.js b/redisinsight/ui/src/packages/redisgraph/src/icons/check.js index 4c0144cc33..45c5436fb0 100644 --- a/redisinsight/ui/src/packages/redisgraph/src/icons/check.js +++ b/redisinsight/ui/src/packages/redisgraph/src/icons/check.js @@ -11,18 +11,25 @@ var EuiIconCheck = function EuiIconCheck(_ref) { titleId = _ref.titleId, props = _objectWithoutProperties(_ref, ["title", "titleId"]); - return /*#__PURE__*/React.createElement("svg", _extends({ - width: 16, - height: 16, - viewBox: "0 0 16 16", - xmlns: "http://www.w3.org/2000/svg", - "aria-labelledby": titleId - }, props), title ? /*#__PURE__*/React.createElement("title", { - id: titleId - }, title) : null, /*#__PURE__*/React.createElement("path", { - fillRule: "evenodd", - d: "M6.5 12a.502.502 0 01-.354-.146l-4-4a.502.502 0 01.708-.708L6.5 10.793l6.646-6.647a.502.502 0 01.708.708l-7 7A.502.502 0 016.5 12" - })); + // For e2e tests. Hammerhead cannot create svg throw createElementNS + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /*#__PURE__*/React.createElement("svg", _extends({ + width: 16, + height: 16, + viewBox: "0 0 16 16", + xmlns: "http://www.w3.org/2000/svg", + "aria-labelledby": titleId + }, props), title ? /*#__PURE__*/React.createElement("title", { + id: titleId + }, title) : null, /*#__PURE__*/React.createElement("path", { + fillRule: "evenodd", + d: "M6.5 12a.502.502 0 01-.354-.146l-4-4a.502.502 0 01.708-.708L6.5 10.793l6.646-6.647a.502.502 0 01.708.708l-7 7A.502.502 0 016.5 12" + })); + } catch (e) { + return + } }; export var icon = EuiIconCheck; diff --git a/redisinsight/ui/src/packages/redisgraph/src/icons/copy.js b/redisinsight/ui/src/packages/redisgraph/src/icons/copy.js index 73146e9dea..35a08cc6a0 100644 --- a/redisinsight/ui/src/packages/redisgraph/src/icons/copy.js +++ b/redisinsight/ui/src/packages/redisgraph/src/icons/copy.js @@ -11,19 +11,26 @@ var EuiIconCopy = function EuiIconCopy(_ref) { titleId = _ref.titleId, props = _objectWithoutProperties(_ref, ["title", "titleId"]); - return /*#__PURE__*/React.createElement("svg", _extends({ - width: 16, - height: 16, - viewBox: "0 0 16 16", - xmlns: "http://www.w3.org/2000/svg", - "aria-labelledby": titleId - }, props), title ? /*#__PURE__*/React.createElement("title", { - id: titleId - }, title) : null, /*#__PURE__*/React.createElement("path", { - d: "M11.4 0c.235 0 .46.099.622.273l2.743 3c.151.162.235.378.235.602v9.25a.867.867 0 01-.857.875H3.857A.867.867 0 013 13.125V.875C3 .392 3.384 0 3.857 0H11.4zM14 4h-2.6a.4.4 0 01-.4-.4V1H4v12h10V4z" - }), /*#__PURE__*/React.createElement("path", { - d: "M3 1H2a1 1 0 00-1 1v13a1 1 0 001 1h10a1 1 0 001-1v-1h-1v1H2V2h1V1z" - })); + // For e2e tests. Hammerhead cannot create svg throw createElementNS + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /*#__PURE__*/React.createElement("svg", _extends({ + width: 16, + height: 16, + viewBox: "0 0 16 16", + xmlns: "http://www.w3.org/2000/svg", + "aria-labelledby": titleId + }, props), title ? /*#__PURE__*/React.createElement("title", { + id: titleId + }, title) : null, /*#__PURE__*/React.createElement("path", { + d: "M11.4 0c.235 0 .46.099.622.273l2.743 3c.151.162.235.378.235.602v9.25a.867.867 0 01-.857.875H3.857A.867.867 0 013 13.125V.875C3 .392 3.384 0 3.857 0H11.4zM14 4h-2.6a.4.4 0 01-.4-.4V1H4v12h10V4z" + }), /*#__PURE__*/React.createElement("path", { + d: "M3 1H2a1 1 0 00-1 1v13a1 1 0 001 1h10a1 1 0 001-1v-1h-1v1H2V2h1V1z" + })); + } catch (e) { + return + } }; export var icon = EuiIconCopy; diff --git a/redisinsight/ui/src/packages/redisgraph/src/icons/cross.js b/redisinsight/ui/src/packages/redisgraph/src/icons/cross.js index 80f56b4b98..c83a8931ec 100644 --- a/redisinsight/ui/src/packages/redisgraph/src/icons/cross.js +++ b/redisinsight/ui/src/packages/redisgraph/src/icons/cross.js @@ -11,17 +11,24 @@ var EuiIconCross = function EuiIconCross(_ref) { titleId = _ref.titleId, props = _objectWithoutProperties(_ref, ["title", "titleId"]); - return /*#__PURE__*/React.createElement("svg", _extends({ - width: 16, - height: 16, - viewBox: "0 0 16 16", - xmlns: "http://www.w3.org/2000/svg", - "aria-labelledby": titleId - }, props), title ? /*#__PURE__*/React.createElement("title", { - id: titleId - }, title) : null, /*#__PURE__*/React.createElement("path", { - d: "M7.293 8L3.146 3.854a.5.5 0 11.708-.708L8 7.293l4.146-4.147a.5.5 0 01.708.708L8.707 8l4.147 4.146a.5.5 0 01-.708.708L8 8.707l-4.146 4.147a.5.5 0 01-.708-.708L7.293 8z" - })); + // For e2e tests. Hammerhead cannot create svg throw createElementNS + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /*#__PURE__*/React.createElement("svg", _extends({ + width: 16, + height: 16, + viewBox: "0 0 16 16", + xmlns: "http://www.w3.org/2000/svg", + "aria-labelledby": titleId + }, props), title ? /*#__PURE__*/React.createElement("title", { + id: titleId + }, title) : null, /*#__PURE__*/React.createElement("path", { + d: "M7.293 8L3.146 3.854a.5.5 0 11.708-.708L8 7.293l4.146-4.147a.5.5 0 01.708.708L8.707 8l4.147 4.146a.5.5 0 01-.708.708L8 8.707l-4.146 4.147a.5.5 0 01-.708-.708L7.293 8z" + })); + } catch (e) { + return + } }; export var icon = EuiIconCross; diff --git a/redisinsight/ui/src/packages/redisgraph/src/icons/empty.js b/redisinsight/ui/src/packages/redisgraph/src/icons/empty.js index 9eb9b8ba43..0dbaf4c5c4 100644 --- a/redisinsight/ui/src/packages/redisgraph/src/icons/empty.js +++ b/redisinsight/ui/src/packages/redisgraph/src/icons/empty.js @@ -11,13 +11,19 @@ var EuiIconEmpty = function EuiIconEmpty(_ref) { titleId = _ref.titleId, props = _objectWithoutProperties(_ref, ["title", "titleId"]); - return /*#__PURE__*/React.createElement("svg", _extends({ - width: 16, - height: 16, - viewBox: "0 0 16 16", - xmlns: "http://www.w3.org/2000/svg", - "aria-labelledby": titleId - }, props)); + try { + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + + return /*#__PURE__*/React.createElement("svg", _extends({ + width: 16, + height: 16, + viewBox: "0 0 16 16", + xmlns: "http://www.w3.org/2000/svg", + "aria-labelledby": titleId + }, props)); + } catch (e) { + return '' + } }; export var icon = EuiIconEmpty; diff --git a/redisinsight/ui/src/packages/redisgraph/src/styles/styles.less b/redisinsight/ui/src/packages/redisgraph/src/styles/styles.less index 8fe3debd8d..a10439250c 100644 --- a/redisinsight/ui/src/packages/redisgraph/src/styles/styles.less +++ b/redisinsight/ui/src/packages/redisgraph/src/styles/styles.less @@ -241,7 +241,7 @@ .responseFail { - color: red; + color: #e06c75; padding: 12px !important; font-family: monospace !important; } diff --git a/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx b/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx index 2742b40933..1ed206e0a0 100644 --- a/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx +++ b/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx @@ -1,8 +1,8 @@ /* eslint-disable sonarjs/no-identical-functions */ import React from 'react' import { render, screen, fireEvent, mockedStore, cleanup } from 'uiSrc/utils/test-utils' -import { setConnectedInstanceId } from 'uiSrc/slices/instances' -import { loadKeys, resetKeyInfo } from 'uiSrc/slices/keys' +import { setConnectedInstanceId } from 'uiSrc/slices/instances/instances' +import { loadKeys, resetKeyInfo, toggleBrowserFullScreen } from 'uiSrc/slices/browser/keys' import { resetErrors } from 'uiSrc/slices/app/notifications' import { cloneDeep } from 'lodash' import BrowserPage from './BrowserPage' @@ -100,7 +100,7 @@ describe('BrowserPage', () => { fireEvent.click(screen.getByTestId('handleAddKeyPanel-btn')) - const expectedActions = [resetKeyInfo()] + const expectedActions = [resetKeyInfo(), toggleBrowserFullScreen(false)] expect(store.getActions()).toEqual([...afterRenderActions, ...expectedActions]) }) @@ -119,6 +119,6 @@ describe('BrowserPage', () => { fireEvent.click(screen.getByTestId('onCloseKey-btn')) - expect(store.getActions()).toEqual([...afterRenderActions, resetKeyInfo()]) + expect(store.getActions()).toEqual([...afterRenderActions, resetKeyInfo(), toggleBrowserFullScreen(true)]) }) }) diff --git a/redisinsight/ui/src/pages/browser/BrowserPage.tsx b/redisinsight/ui/src/pages/browser/BrowserPage.tsx index 4de8c78cf9..218103b6b3 100644 --- a/redisinsight/ui/src/pages/browser/BrowserPage.tsx +++ b/redisinsight/ui/src/pages/browser/BrowserPage.tsx @@ -15,8 +15,9 @@ import { resetKeyInfo, selectedKeyDataSelector, setInitialStateByType, -} from 'uiSrc/slices/keys' -import { connectedInstanceSelector, setConnectedInstanceId } from 'uiSrc/slices/instances' + toggleBrowserFullScreen, +} from 'uiSrc/slices/browser/keys' +import { connectedInstanceSelector, setConnectedInstanceId } from 'uiSrc/slices/instances/instances' import { setBrowserKeyListDataLoaded, setBrowserSelectedKey, @@ -52,7 +53,7 @@ const BrowserPage = () => { panelSizes } = useSelector(appContextBrowser) const keysState = useSelector(keysDataSelector) - const { loading, viewType } = useSelector(keysSelector) + const { loading, viewType, isBrowserFullScreen } = useSelector(keysSelector) const { type } = useSelector(selectedKeyDataSelector) ?? { type: '' } const [isPageViewSent, setIsPageViewSent] = useState(false) @@ -111,6 +112,10 @@ const BrowserPage = () => { })) }, []) + const handleToggleFullScreen = () => { + dispatch(toggleBrowserFullScreen()) + } + const sendPageView = (instanceId: string) => { sendPageViewTelemetry({ name: TelemetryPageView.BROWSER_PAGE, @@ -129,16 +134,19 @@ const BrowserPage = () => { )) } - const handleAddKeyPanel = (value: boolean) => { + const handleAddKeyPanel = (value: boolean, keyName?: string) => { if (value && !isAddKeyPanelOpen) { dispatch(resetKeyInfo()) - setSelectedKey(null) } + setSelectedKey(keyName ?? null) + dispatch(toggleBrowserFullScreen(false)) setIsAddKeyPanelOpen(value) } const selectKey = ({ rowData }: { rowData: any }) => { if (rowData.name !== selectedKey) { + dispatch(toggleBrowserFullScreen(false)) + dispatch(setInitialStateByType(prevSelectedType.current)) setSelectedKey(rowData.name) setIsAddKeyPanelOpen(false) @@ -148,6 +156,7 @@ const BrowserPage = () => { const closeKey = () => { dispatch(resetKeyInfo()) + dispatch(toggleBrowserFullScreen(true)) setSelectedKey(null) setIsAddKeyPanelOpen(false) @@ -167,6 +176,8 @@ const BrowserPage = () => { } } + const isRightPanelOpen = selectedKey !== null || isAddKeyPanelOpen + return (
@@ -179,21 +190,19 @@ const BrowserPage = () => { id={firstPanelId} scrollable={false} initialSize={sizes[firstPanelId] ?? 50} - minSize="600px" + minSize="550px" paddingSize="none" wrapperProps={{ className: cx(styles.resizePanelLeft, { - [styles.fullWidth]: arePanelsCollapsed, + [styles.fullWidth]: arePanelsCollapsed || (isBrowserFullScreen && !isRightPanelOpen) }), }} > <> - @@ -210,7 +219,6 @@ const BrowserPage = () => { )} @@ -219,7 +227,7 @@ const BrowserPage = () => { @@ -228,13 +236,15 @@ const BrowserPage = () => { id={secondPanelId} scrollable={false} initialSize={sizes[secondPanelId] ?? 50} - minSize="500px" + minSize="550px" paddingSize="none" + data-testid="key-details" wrapperProps={{ className: cx(styles.resizePanelRight, { - [styles.fullWidth]: arePanelsCollapsed, - [styles.keyDetails]: arePanelsCollapsed, - [styles.keyDetailsOpen]: isAddKeyPanelOpen || selectedKey !== null, + [styles.noVisible]: isBrowserFullScreen && !isRightPanelOpen, + [styles.fullWidth]: arePanelsCollapsed || (isBrowserFullScreen && isRightPanelOpen), + [styles.keyDetails]: arePanelsCollapsed || (isBrowserFullScreen && isRightPanelOpen), + [styles.keyDetailsOpen]: isRightPanelOpen, }), }} > @@ -245,6 +255,9 @@ const BrowserPage = () => { /> ) : ( handleEditKey(key, newKey)} diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKey.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKey.tsx index 495ff0b2cd..97519ce73f 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKey.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKey.tsx @@ -4,19 +4,19 @@ import cx from 'classnames' import { EuiFlexGroup, EuiFlexItem, - EuiSuperSelect, EuiHealth, EuiTitle, - EuiFormFieldset, - EuiFormRow, EuiToolTip, EuiButtonIcon, } from '@elastic/eui' +import Divider from 'uiSrc/components/divider/Divider' import { KeyTypes } from 'uiSrc/constants' import HelpTexts from 'uiSrc/constants/help-texts' -import { addKeyStateSelector, resetAddKey, keysSelector } from 'uiSrc/slices/keys' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import AddKeyCommonFields from 'uiSrc/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields' +import { addKeyStateSelector, resetAddKey, keysSelector } from 'uiSrc/slices/browser/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent } from 'uiSrc/telemetry' +import { Maybe } from 'uiSrc/utils' import { ADD_KEY_TYPE_OPTIONS } from './constants/key-type-options' import AddKeyHash from './AddKeyHash/AddKeyHash' import AddKeyZset from './AddKeyZset/AddKeyZset' @@ -24,12 +24,13 @@ import AddKeyString from './AddKeyString/AddKeyString' import AddKeySet from './AddKeySet/AddKeySet' import AddKeyList from './AddKeyList/AddKeyList' import AddKeyReJSON from './AddKeyReJSON/AddKeyReJSON' +import AddKeyStream from './AddKeyStream/AddKeyStream' import styles from './styles.module.scss' export interface Props { - handleAddKeyPanel: (value: boolean) => void; - handleCloseKey: () => void; + handleAddKeyPanel: (value: boolean, keyName?: string) => void + handleCloseKey: () => void } const AddKey = (props: Props) => { const { handleAddKeyPanel, handleCloseKey } = props @@ -58,6 +59,8 @@ const AddKey = (props: Props) => { } }) const [typeSelected, setTypeSelected] = useState(options[0].value) + const [keyName, setKeyName] = useState('') + const [keyTTL, setKeyTTL] = useState>(undefined) const onChangeType = (value: string) => { setTypeSelected(value) @@ -82,12 +85,18 @@ const AddKey = (props: Props) => { } const closeAddKeyPanel = (isCancelled?: boolean) => { - handleAddKeyPanel(false) + handleAddKeyPanel(false, keyName) if (isCancelled) { + handleCloseKey() closeKeyTelemetry() } } + const defaultFields = { + keyName, + keyTTL + } + return (
{ className={cx(styles.contentWrapper, 'relative')} gutterSize="none" > -
- - - -

- Add key -

-
- - closeKey()} - /> - -
- + + +

New Key

+
+ - - {HelpTexts.REJSON_SHOULD_BE_LOADED} - - ) - : null - } - fullWidth - > - onChangeType(value)} - /> - -
- {typeSelected === KeyTypes.Hash && ( - - )} - {typeSelected === KeyTypes.ZSet && ( - - )} - {typeSelected === KeyTypes.Set && ( - - )} - {typeSelected === KeyTypes.String && ( - - )} - {typeSelected === KeyTypes.List && ( - - )} - {typeSelected === KeyTypes.ReJSON && ( - - )} -
-
+ closeKey()} + /> + + +
+
+ + + + + {typeSelected === KeyTypes.Hash && ( + + )} + {typeSelected === KeyTypes.ZSet && ( + + )} + {typeSelected === KeyTypes.Set && ( + + )} + {typeSelected === KeyTypes.String && ( + + )} + {typeSelected === KeyTypes.List && ( + + )} + {typeSelected === KeyTypes.ReJSON && ( + <> + + {HelpTexts.REJSON_SHOULD_BE_LOADED} + + + + )} + {typeSelected === KeyTypes.Stream && ( + + )} +
+
+
diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields.spec.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields.spec.tsx index 4b644c9e13..38f7098ff8 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields.spec.tsx @@ -6,6 +6,7 @@ import { AddCommonFieldsFormConfig } from 'uiSrc/pages/browser/components/add-ke import AddKeyCommonFields, { Props } from './AddKeyCommonFields' const mockedProps = mock() +const options = ['one', 'two'] describe('AddKeyCommonFields', () => { it('should render', () => { @@ -13,7 +14,7 @@ describe('AddKeyCommonFields', () => { render( ) ).toBeTruthy() @@ -25,7 +26,7 @@ describe('AddKeyCommonFields', () => { ) const ttlInput = screen.getByPlaceholderText(AddCommonFieldsFormConfig.keyName.placeholder) @@ -43,7 +44,7 @@ describe('AddKeyCommonFields', () => { ) const ttlInput = screen.getByPlaceholderText(AddCommonFieldsFormConfig.keyTTL.placeholder) @@ -65,7 +66,7 @@ describe('AddKeyCommonFields', () => { {...instance(mockedProps)} // @ts-ignore setKeyTTL={setKeyTTL} - config={AddCommonFieldsFormConfig} + options={options} /> ) const ttlInput = screen.getByPlaceholderText(AddCommonFieldsFormConfig.keyTTL.placeholder) diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields.tsx index ff5f8060a4..3303bc4e7a 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields.tsx @@ -1,22 +1,35 @@ import React, { ChangeEvent } from 'react' import { toNumber } from 'lodash' -import { EuiFieldText, EuiFormRow } from '@elastic/eui' - +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormFieldset, + EuiFormRow, + EuiSuperSelect +} from '@elastic/eui' import { MAX_TTL_NUMBER, Maybe, validateTTLNumberForAddKey } from 'uiSrc/utils' -import { IAddCommonFieldsFormConfig } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' + +import { AddCommonFieldsFormConfig as config } from '../constants/fields-config' + +import styles from './styles.module.scss' export interface Props { - config: IAddCommonFieldsFormConfig, - loading: boolean, - keyName: string, - setKeyName: React.Dispatch>, - keyTTL: Maybe, + typeSelected: string + onChangeType: (type: string) => void + options: any + loading: boolean + keyName: string + setKeyName: React.Dispatch> + keyTTL: Maybe setKeyTTL: React.Dispatch>> } const AddKeyCommonFields = (props: Props) => { const { - config, + typeSelected, + onChangeType = () => {}, + options, loading, keyName, setKeyName, @@ -36,7 +49,46 @@ const AddKeyCommonFields = (props: Props) => { } return ( - <> +
+ + + + + onChangeType(value)} + /> + + + + + + + + + { onChange={(e: ChangeEvent) => setKeyName(e.target.value)} disabled={loading} + autoComplete="off" data-testid="key" /> - - - - +
) } diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/styles.module.scss b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/styles.module.scss new file mode 100644 index 0000000000..2e3d27f62e --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/styles.module.scss @@ -0,0 +1,8 @@ +.wrapper { + .container { + &:global(.euiFlexGroup.euiFlexGroup--gutterLarge) { + margin: -12px -12px 12px !important; + } + } +} + diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyHash/AddKeyHash.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyHash/AddKeyHash.tsx index f7e3687769..4d06fd2c9c 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyHash/AddKeyHash.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyHash/AddKeyHash.tsx @@ -11,12 +11,10 @@ import { EuiFlexItem, EuiPanel, } from '@elastic/eui' -import { Maybe } from 'uiSrc/utils' import { addHashKey, addKeyStateSelector, -} from 'uiSrc/slices/keys' +} from 'uiSrc/slices/browser/keys' import { CreateHashWithExpireDto } from 'apiSrc/modules/browser/dto/hash.dto' -import AddKeyCommonFields from 'uiSrc/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields' import { IHashFieldState, INITIAL_HASH_FIELD_STATE @@ -24,20 +22,21 @@ import { import AddItemsActions from 'uiSrc/pages/browser/components/add-items-actions/AddItemsActions' import styles from 'uiSrc/pages/browser/components/key-details-add-items/styles.module.scss' +import { Maybe } from 'uiSrc/utils' import { - AddCommonFieldsFormConfig as defaultConfig, AddHashFormConfig as config } from '../constants/fields-config' import AddKeyFooter from '../AddKeyFooter/AddKeyFooter' export interface Props { - onCancel: (isCancelled?: boolean) => void; + keyName: string + keyTTL: Maybe + onCancel: (isCancelled?: boolean) => void } const AddKeyHash = (props: Props) => { + const { keyName = '', keyTTL, onCancel } = props const { loading } = useSelector(addKeyStateSelector) - const [keyName, setKeyName] = useState('') - const [keyTTL, setKeyTTL] = useState>(undefined) const [fields, setFields] = useState([{ ...INITIAL_HASH_FIELD_STATE }]) const [isFormValid, setIsFormValid] = useState(false) const lastAddedFieldName = useRef(null) @@ -118,7 +117,7 @@ const AddKeyHash = (props: Props) => { if (keyTTL !== undefined) { data.expire = keyTTL } - dispatch(addHashKey(data, props.onCancel)) + dispatch(addHashKey(data, onCancel)) } const isClearDisabled = (item: IHashFieldState): boolean => @@ -126,14 +125,6 @@ const AddKeyHash = (props: Props) => { return ( - { fields.map((item, index) => ( {
props.onCancel(true)} + onClick={() => onCancel(true)} className="btn-cancel btn-back" > Cancel diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyList/AddKeyList.spec.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyList/AddKeyList.spec.tsx index dc5e60904e..1e2e319de1 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyList/AddKeyList.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyList/AddKeyList.spec.tsx @@ -37,10 +37,7 @@ describe('AddKeyList', () => { }) it('should not be disabled add key with proper values', () => { - const { container } = render() - const keyNameInput = screen.getByTestId('key') - const value = 'key name' - fireEvent.change(keyNameInput, { target: { value } }) + const { container } = render() expect(container.querySelector('.btn-add')).not.toBeDisabled() }) }) diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyList/AddKeyList.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyList/AddKeyList.tsx index d4b7f46489..37d27d693e 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyList/AddKeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyList/AddKeyList.tsx @@ -13,23 +13,22 @@ import { } from '@elastic/eui' import { Maybe } from 'uiSrc/utils' -import { addKeyStateSelector, addListKey } from 'uiSrc/slices/keys' +import { addKeyStateSelector, addListKey } from 'uiSrc/slices/browser/keys' import { CreateListWithExpireDto } from 'apiSrc/modules/browser/dto' import { AddListFormConfig as config, - AddCommonFieldsFormConfig as defaultConfig, } from '../constants/fields-config' import AddKeyFooter from '../AddKeyFooter/AddKeyFooter' -import AddKeyCommonFields from '../AddKeyCommonFields/AddKeyCommonFields' export interface Props { - onCancel: (isCancelled?: boolean) => void; + keyName: string + keyTTL: Maybe + onCancel: (isCancelled?: boolean) => void } const AddKeyList = (props: Props) => { - const [keyName, setKeyName] = useState('') - const [keyTTL, setKeyTTL] = useState>(undefined) + const { keyName = '', keyTTL, onCancel } = props const [element, setElement] = useState('') const [isFormValid, setIsFormValid] = useState(false) @@ -56,19 +55,11 @@ const AddKeyList = (props: Props) => { if (keyTTL !== undefined) { data.expire = keyTTL } - dispatch(addListKey(data, props.onCancel)) + dispatch(addListKey(data, onCancel)) } return ( - { props.onCancel(true)} + onClick={() => onCancel(true)} className="btn-cancel btn-back" > Cancel diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyReJSON/AddKeyReJSON.spec.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyReJSON/AddKeyReJSON.spec.tsx index fc4b4a3149..9a19def0cb 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyReJSON/AddKeyReJSON.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyReJSON/AddKeyReJSON.spec.tsx @@ -43,13 +43,8 @@ describe('AddKeyReJSON', () => { }) it('should render add button disabled with wrong json', () => { - render() + render() const valueArea = screen.getByTestId('json-value') - const keyNameInput = screen.getByTestId('key') - fireEvent.change( - keyNameInput, - { target: { value: 'keyName' } } - ) fireEvent.change( valueArea, { target: { value: '{"12' } } @@ -58,13 +53,8 @@ describe('AddKeyReJSON', () => { }) it('should render add button not disabled', () => { - render() + render() const valueArea = screen.getByTestId('json-value') - const keyNameInput = screen.getByTestId('key') - fireEvent.change( - keyNameInput, - { target: { value: 'keyName' } } - ) fireEvent.change( valueArea, { target: { value: '{}' } } diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyReJSON/AddKeyReJSON.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyReJSON/AddKeyReJSON.tsx index 58184c7f86..ba9109964b 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyReJSON/AddKeyReJSON.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyReJSON/AddKeyReJSON.tsx @@ -10,26 +10,25 @@ import { EuiPanel, EuiTextArea, } from '@elastic/eui' import { Maybe } from 'uiSrc/utils' -import { addKeyStateSelector, addReJSONKey, } from 'uiSrc/slices/keys' +import { addKeyStateSelector, addReJSONKey, } from 'uiSrc/slices/browser/keys' import { CreateRejsonRlWithExpireDto } from 'apiSrc/modules/browser/dto' import { - AddCommonFieldsFormConfig as defaultConfig, AddJSONFormConfig as config } from '../constants/fields-config' import AddKeyFooter from '../AddKeyFooter/AddKeyFooter' -import AddKeyCommonFields from '../AddKeyCommonFields/AddKeyCommonFields' export interface Props { - onCancel: (isCancelled?: boolean) => void; + keyName: string + keyTTL: Maybe + onCancel: (isCancelled?: boolean) => void } const AddKeyReJSON = (props: Props) => { + const { keyName = '', keyTTL, onCancel } = props const { loading } = useSelector(addKeyStateSelector) - const [keyName, setKeyName] = useState('') - const [keyTTL, setKeyTTL] = useState>(undefined) const [ReJSONValue, setReJSONValue] = useState('') const [isFormValid, setIsFormValid] = useState(false) @@ -65,20 +64,11 @@ const AddKeyReJSON = (props: Props) => { if (keyTTL !== undefined) { data.expire = keyTTL } - dispatch(addReJSONKey(data, props.onCancel)) + dispatch(addReJSONKey(data, onCancel)) } return ( - - {
props.onCancel(true)} + onClick={() => onCancel(true)} className="btn-cancel btn-back" > Cancel diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeySet/AddKeySet.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeySet/AddKeySet.tsx index f1ab6d823d..deb580eb61 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeySet/AddKeySet.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeySet/AddKeySet.tsx @@ -14,13 +14,11 @@ import { import { Maybe } from 'uiSrc/utils' import { addSetKey, addKeyStateSelector, -} from 'uiSrc/slices/keys' -import AddKeyCommonFields from 'uiSrc/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields' +} from 'uiSrc/slices/browser/keys' import AddItemsActions from 'uiSrc/pages/browser/components/add-items-actions/AddItemsActions' import { CreateSetWithExpireDto } from 'apiSrc/modules/browser/dto/set.dto' import { - AddCommonFieldsFormConfig as defaultConfig, AddSetFormConfig as config } from '../constants/fields-config' import AddKeyFooter from '../AddKeyFooter/AddKeyFooter' @@ -33,13 +31,14 @@ import { import styles from '../../key-details-add-items/styles.module.scss' export interface Props { + keyName: string + keyTTL: Maybe onCancel: (isCancelled?: boolean) => void; } const AddKeySet = (props: Props) => { + const { keyName = '', keyTTL, onCancel } = props const { loading } = useSelector(addKeyStateSelector) - const [keyName, setKeyName] = useState('') - const [keyTTL, setKeyTTL] = useState>(undefined) const [members, setMembers] = useState([{ ...INITIAL_SET_MEMBER_STATE }]) const [isFormValid, setIsFormValid] = useState(false) const lastAddedMemberName = useRef(null) @@ -116,21 +115,13 @@ const AddKeySet = (props: Props) => { if (keyTTL !== undefined) { data.expire = keyTTL } - dispatch(addSetKey(data, props.onCancel)) + dispatch(addSetKey(data, onCancel)) } const isClearDisabled = (item: ISetMemberState): boolean => members.length === 1 && !item.name.length return ( - { @@ -172,7 +163,6 @@ const AddKeySet = (props: Props) => { length={members.length} addItem={addMember} removeItem={removeMember} - removeCanClear clearIsDisabled={isClearDisabled(item)} clearItemValues={clearMemberValues} loading={loading} @@ -199,7 +189,7 @@ const AddKeySet = (props: Props) => { props.onCancel(true)} + onClick={() => onCancel(true)} className="btn-cancel btn-back" > Cancel diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/AddKeyStream.spec.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/AddKeyStream.spec.tsx new file mode 100644 index 0000000000..21cda37a44 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/AddKeyStream.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { instance, mock } from 'ts-mockito' +import AddKeyStream, { Props } from './AddKeyStream' + +const mockedProps = mock() + +describe('AddKeyStream', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/AddKeyStream.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/AddKeyStream.tsx new file mode 100644 index 0000000000..3928ecc14e --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/AddKeyStream.tsx @@ -0,0 +1,131 @@ +import { keyBy, mapValues } from 'lodash' +import React, { FormEvent, useEffect, useState } from 'react' +import { useDispatch } from 'react-redux' +import { + EuiButton, + EuiTextColor, + EuiForm, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, +} from '@elastic/eui' +import { addStreamKey } from 'uiSrc/slices/browser/keys' +import { entryIdRegex, isRequiredStringsValid, Maybe } from 'uiSrc/utils' +import { StreamEntryFields } from 'uiSrc/pages/browser/components/key-details-add-items' +import { AddStreamFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' +import { CreateStreamDto } from 'apiSrc/modules/browser/dto/stream.dto' +import AddKeyFooter from '../AddKeyFooter/AddKeyFooter' + +import styles from './styles.module.scss' + +export interface Props { + keyName: string + keyTTL: Maybe + onCancel: (isCancelled?: boolean) => void +} + +export const INITIAL_STREAM_FIELD_STATE = { + fieldName: '', + fieldValue: '', + id: 0, +} + +const AddKeyStream = (props: Props) => { + const { keyName = '', keyTTL, onCancel } = props + + const [entryIdError, setEntryIdError] = useState('') + const [entryID, setEntryID] = useState('*') + const [fields, setFields] = useState([{ ...INITIAL_STREAM_FIELD_STATE }]) + const [isFormValid, setIsFormValid] = useState(false) + + const dispatch = useDispatch() + + useEffect(() => { + const isValid = isRequiredStringsValid(keyName) && !entryIdError + setIsFormValid(isValid) + }, [keyName, fields, entryIdError]) + + useEffect(() => { + validateEntryID() + }, [entryID]) + + const validateEntryID = () => { + setEntryIdError(entryIdRegex.test(entryID) ? '' : `${config.entryId.name} format is incorrect`) + } + + const onFormSubmit = (event: FormEvent): void => { + event.preventDefault() + if (isFormValid) { + submitData() + } + } + + const submitData = (): void => { + const data: CreateStreamDto = { + keyName, + entries: [{ + id: entryID, + fields: mapValues(keyBy(fields, 'fieldName'), 'fieldValue') + }] + } + if (keyTTL !== undefined) { + data.expire = keyTTL + } + dispatch(addStreamKey(data, onCancel)) + } + + return ( + + + + Submit + + + + + +
+ onCancel(true)} + className="btn-cancel btn-back" + > + Cancel + +
+
+ +
+ + Add Key + +
+
+
+
+
+
+ ) +} + +export default AddKeyStream diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/styles.module.scss b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/styles.module.scss new file mode 100644 index 0000000000..a2a82e9d6a --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyStream/styles.module.scss @@ -0,0 +1,4 @@ +.container { + +} + diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyString/AddKeyString.spec.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyString/AddKeyString.spec.tsx index 84f862316b..f9d6902c05 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyString/AddKeyString.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyString/AddKeyString.spec.tsx @@ -42,13 +42,7 @@ describe('AddKeyString', () => { }) it('should not be disabled add key with proper values', () => { - const { container } = render() - const keyNameInput = screen.getByTestId('key') - const value = 'key name' - fireEvent.change( - keyNameInput, - { target: { value } } - ) + const { container } = render() expect(container.querySelector('.btn-add')).not.toBeDisabled() }) }) diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyString/AddKeyString.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyString/AddKeyString.tsx index df8726ddcf..bb6cd263c7 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyString/AddKeyString.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyString/AddKeyString.tsx @@ -12,25 +12,23 @@ import { } from '@elastic/eui' import { Maybe } from 'uiSrc/utils' -import { addKeyStateSelector, addStringKey } from 'uiSrc/slices/keys' -import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { addKeyStateSelector, addStringKey } from 'uiSrc/slices/browser/keys' -import AddKeyCommonFields from 'uiSrc/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields' import { SetStringWithExpireDto } from 'apiSrc/modules/browser/dto' import AddKeyFooter from '../AddKeyFooter/AddKeyFooter' import { - AddCommonFieldsFormConfig as defaultConfig, AddStringFormConfig as config } from '../constants/fields-config' export interface Props { - onCancel: (isCancelled?: boolean) => void; + keyName: string + keyTTL: Maybe + onCancel: (isCancelled?: boolean) => void } const AddKeyString = (props: Props) => { + const { keyName = '', keyTTL, onCancel } = props const { loading } = useSelector(addKeyStateSelector) - const [keyName, setKeyName] = useState('') - const [keyTTL, setKeyTTL] = useState>(undefined) const [value, setValue] = useState('') const [isFormValid, setIsFormValid] = useState(false) @@ -55,19 +53,11 @@ const AddKeyString = (props: Props) => { if (keyTTL !== undefined) { data.expire = keyTTL } - dispatch(addStringKey(data, props.onCancel)) + dispatch(addStringKey(data, onCancel)) } return ( - {
props.onCancel(true)} + onClick={() => onCancel(true)} className="btn-cancel btn-back" > Cancel diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyZset/AddKeyZset.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyZset/AddKeyZset.tsx index 0da2b66b72..74bb326621 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKeyZset/AddKeyZset.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKeyZset/AddKeyZset.tsx @@ -14,33 +14,29 @@ import { } from '@elastic/eui' import { Maybe, validateScoreNumber } from 'uiSrc/utils' import { isNaNConvertedString } from 'uiSrc/utils/numbers' -import { - addZsetKey, addKeyStateSelector, -} from 'uiSrc/slices/keys' +import { addZsetKey, addKeyStateSelector } from 'uiSrc/slices/browser/keys' import { CreateZSetWithExpireDto } from 'apiSrc/modules/browser/dto/z-set.dto' -import AddKeyCommonFields from 'uiSrc/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields' import AddItemsActions from 'uiSrc/pages/browser/components/add-items-actions/AddItemsActions' import styles from 'uiSrc/pages/browser/components/key-details-add-items/styles.module.scss' import AddKeyFooter from '../AddKeyFooter/AddKeyFooter' -import { - AddCommonFieldsFormConfig as defaultConfig, - AddZsetFormConfig as config -} from '../constants/fields-config' +import { AddZsetFormConfig as config } from '../constants/fields-config' import { - INITIAL_ZSET_MEMBER_STATE, IZsetMemberState + INITIAL_ZSET_MEMBER_STATE, + IZsetMemberState } from '../../key-details-add-items/add-zset-members/AddZsetMembers' export interface Props { - onCancel: (isCancelled?: boolean) => void; + keyName: string + keyTTL: Maybe + onCancel: (isCancelled?: boolean) => void } const AddKeyZset = (props: Props) => { + const { keyName = '', keyTTL, onCancel } = props const { loading } = useSelector(addKeyStateSelector) - const [keyName, setKeyName] = useState('') - const [keyTTL, setKeyTTL] = useState>(undefined) const [members, setMembers] = useState([{ ...INITIAL_ZSET_MEMBER_STATE }]) const [isFormValid, setIsFormValid] = useState(false) const lastAddedMemberName = useRef(null) @@ -166,7 +162,7 @@ const AddKeyZset = (props: Props) => { if (keyTTL !== undefined) { data.expire = keyTTL } - dispatch(addZsetKey(data, props.onCancel)) + dispatch(addZsetKey(data, onCancel)) } const isClearDisabled = (item: IZsetMemberState): boolean => @@ -174,14 +170,6 @@ const AddKeyZset = (props: Props) => { return ( - { members.map((item, index) => ( { length={members.length} addItem={addMember} removeItem={removeMember} - removeCanClear clearIsDisabled={isClearDisabled(item)} addItemIsDisabled={(members.some((item) => !item.score.length))} clearItemValues={clearMemberValues} @@ -269,7 +256,7 @@ const AddKeyZset = (props: Props) => {
props.onCancel(true)} + onClick={() => onCancel(true)} className="btn-cancel btn-back" > Cancel diff --git a/redisinsight/ui/src/pages/browser/components/add-key/constants/fields-config.ts b/redisinsight/ui/src/pages/browser/components/add-key/constants/fields-config.ts index 94304a7f00..d4bd41bb30 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/constants/fields-config.ts +++ b/redisinsight/ui/src/pages/browser/components/add-key/constants/fields-config.ts @@ -1,13 +1,14 @@ interface IFormField { - name: string; - isRequire: boolean; - label: string; - placeholder: string; + id?: string + name: string + isRequire: boolean + label: string + placeholder: string } export interface IAddCommonFieldsFormConfig { - keyName: IFormField; - keyTTL: IFormField; + keyName: IFormField + keyTTL: IFormField } export const AddCommonFieldsFormConfig: IAddCommonFieldsFormConfig = { @@ -26,25 +27,11 @@ export const AddCommonFieldsFormConfig: IAddCommonFieldsFormConfig = { } interface IAddHashFormConfig { - keyName: IFormField; - keyTTL: IFormField; - fieldName: IFormField; - fieldValue: IFormField; + fieldName: IFormField + fieldValue: IFormField } export const AddHashFormConfig: IAddHashFormConfig = { - keyName: { - name: 'keyName', - isRequire: true, - label: 'Key name*', - placeholder: 'Key Name', - }, - keyTTL: { - name: 'keyTTL', - isRequire: false, - label: 'TTL', - placeholder: 'No limit', - }, fieldName: { name: 'fieldName', isRequire: false, @@ -60,8 +47,8 @@ export const AddHashFormConfig: IAddHashFormConfig = { } interface IAddZsetFormConfig { - score: IFormField; - member: IFormField; + score: IFormField + member: IFormField } export const AddZsetFormConfig: IAddZsetFormConfig = { @@ -80,7 +67,7 @@ export const AddZsetFormConfig: IAddZsetFormConfig = { } interface IAddSetFormConfig { - member: IFormField; + member: IFormField } export const AddSetFormConfig: IAddSetFormConfig = { @@ -93,7 +80,7 @@ export const AddSetFormConfig: IAddSetFormConfig = { } interface IAddStringFormConfig { - value: IFormField; + value: IFormField } export const AddStringFormConfig: IAddStringFormConfig = { @@ -106,8 +93,8 @@ export const AddStringFormConfig: IAddStringFormConfig = { } interface IAddListFormConfig { - element: IFormField; - count: IFormField; + element: IFormField + count: IFormField } export const AddListFormConfig: IAddListFormConfig = { @@ -126,7 +113,7 @@ export const AddListFormConfig: IAddListFormConfig = { } interface IAddJSONFormConfig { - value: IFormField; + value: IFormField } export const AddJSONFormConfig: IAddJSONFormConfig = { @@ -137,3 +124,33 @@ export const AddJSONFormConfig: IAddJSONFormConfig = { placeholder: 'Enter JSON', }, } + +interface IAddStreamFormConfig { + entryId: IFormField + fieldName: IFormField + fieldValue: IFormField +} + +export const AddStreamFormConfig: IAddStreamFormConfig = { + entryId: { + id: 'entryId', + name: 'Entry ID', + isRequire: true, + label: 'Entry ID*', + placeholder: 'Enter Entry ID' + }, + fieldName: { + id: 'fieldName', + name: 'Field Name', + isRequire: false, + label: 'Field', + placeholder: 'Enter Field', + }, + fieldValue: { + id: 'fieldValue', + name: 'Field Value', + isRequire: false, + label: 'Value', + placeholder: 'Enter Value', + }, +} diff --git a/redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts b/redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts index 04123803a6..af22bb255c 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts +++ b/redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts @@ -31,4 +31,9 @@ export const ADD_KEY_TYPE_OPTIONS = [ value: KeyTypes.ReJSON, color: GROUP_TYPES_COLORS[KeyTypes.ReJSON], }, + { + text: 'Stream', + value: KeyTypes.Stream, + color: GROUP_TYPES_COLORS[KeyTypes.Stream], + }, ] diff --git a/redisinsight/ui/src/pages/browser/components/add-key/styles.module.scss b/redisinsight/ui/src/pages/browser/components/add-key/styles.module.scss index 83def0b257..bd619e9e4a 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/add-key/styles.module.scss @@ -19,20 +19,36 @@ height: 1px !important; width: 100%; position: relative; - padding: 20px 55px 80px 55px; - max-width: 708px; + padding: 24px 18px 96px 18px; scroll-padding-bottom: 80px; @media screen and (max-width: 767px) { - padding: 20px 15px 96px 15px; scroll-padding-bottom: 96px; } } +.contentFields { + max-width: 680px; + margin: 0 auto; + width: 100%; +} + +.divider { + margin-top: 30px; + margin-bottom: 30px; +} + +.helpText { + color: var(--euiTextSubduedColor); + display: block; + margin-bottom: 12px; + font: normal normal normal 14px/24px Graphik; +} + .closeKeyTooltip { position: absolute; - top: 8px; - right: 10px; + top: 22px; + right: 18px; svg { width: 20px; diff --git a/redisinsight/ui/src/pages/browser/components/auto-refresh/AutoRefresh.spec.tsx b/redisinsight/ui/src/pages/browser/components/auto-refresh/AutoRefresh.spec.tsx new file mode 100644 index 0000000000..cdeb5afd43 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/auto-refresh/AutoRefresh.spec.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { fireEvent, screen, render } from 'uiSrc/utils/test-utils' +import AutoRefresh, { Props } from './AutoRefresh' +import { DEFAULT_REFRESH_RATE } from './utils' + +const mockedProps = mock() + +const INLINE_ITEM_EDITOR = 'inline-item-editor' + +describe('AutoRefresh', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('prop "displayText = true" should show Refresh text', () => { + const { queryByTestId } = render() + + expect(queryByTestId('refresh-message-label')).toBeInTheDocument() + }) + + it('prop "displayText = false" should hide Refresh text', () => { + const { queryByTestId } = render() + + expect(queryByTestId('refresh-message-label')).not.toBeInTheDocument() + }) + + it('should call onRefresh', () => { + const onRefresh = jest.fn() + render() + + fireEvent.click(screen.getByTestId('refresh-key-btn')) + expect(onRefresh).toBeCalled() + }) + + it('refresh text should contain "Last refresh" time with disabled auto-refresh', async () => { + render() + + expect(screen.getByTestId('refresh-message-label')).toHaveTextContent(/Last refresh:/i) + expect(screen.getByTestId('refresh-message')).toHaveTextContent('now') + }) + + it('refresh text should contain "Auto-refresh" time with enabled auto-refresh', async () => { + render() + + fireEvent.click(screen.getByTestId('auto-refresh-config-btn')) + fireEvent.click(screen.getByTestId('auto-refresh-switch')) + + expect(screen.getByTestId('refresh-message-label')).toHaveTextContent(/Auto refresh:/i) + expect(screen.getByTestId('refresh-message')).toHaveTextContent(DEFAULT_REFRESH_RATE) + }) + + describe('AutoRefresh Config', () => { + it('Auto refresh config should render', () => { + const { queryByTestId } = render() + + fireEvent.click(screen.getByTestId('auto-refresh-config-btn')) + expect(queryByTestId('auto-refresh-switch')).toBeInTheDocument() + }) + + it('should call onRefresh after enable auto-refresh and set 1 sec', async () => { + const onRefresh = jest.fn() + render() + + fireEvent.click(screen.getByTestId('auto-refresh-config-btn')) + fireEvent.click(screen.getByTestId('auto-refresh-switch')) + fireEvent.click(screen.getByTestId('refresh-rate')) + + fireEvent.change(screen.getByTestId(INLINE_ITEM_EDITOR), { target: { value: '1' } }) + expect(screen.getByTestId(INLINE_ITEM_EDITOR)).toHaveValue('1') + + screen.getByTestId(/apply-btn/).click() + + await new Promise((r) => setTimeout(r, 1100)) + expect(onRefresh).toBeCalledTimes(1) + await new Promise((r) => setTimeout(r, 1100)) + expect(onRefresh).toBeCalledTimes(2) + await new Promise((r) => setTimeout(r, 1100)) + expect(onRefresh).toBeCalledTimes(3) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/auto-refresh/AutoRefresh.tsx b/redisinsight/ui/src/pages/browser/components/auto-refresh/AutoRefresh.tsx new file mode 100644 index 0000000000..7c22a8c633 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/auto-refresh/AutoRefresh.tsx @@ -0,0 +1,247 @@ +import React, { useEffect, useState } from 'react' +import { EuiButtonIcon, EuiIcon, EuiPopover, EuiSwitch, EuiTextColor, EuiToolTip } from '@elastic/eui' +import cx from 'classnames' + +import { + errorValidateRefreshRateNumber, + MIN_REFRESH_RATE, + Nullable, + validateRefreshRateNumber +} from 'uiSrc/utils' +import InlineItemEditor from 'uiSrc/components/inline-item-editor' +import { localStorageService } from 'uiSrc/services' +import { BrowserStorageItem } from 'uiSrc/constants' +import { + getTextByRefreshTime, + DEFAULT_REFRESH_RATE, + DURATION_FIRST_REFRESH_TIME, + MINUTE, + NOW, +} from './utils' + +import styles from './styles.module.scss' + +export interface Props { + postfix: string + loading: boolean + displayText?: boolean + lastRefreshTime: Nullable + testid?: string + containerClassName?: string + turnOffAutoRefresh?: boolean + onRefresh: (enableAutoRefresh?: boolean) => void + onEnableAutoRefresh?: (enableAutoRefresh: boolean, refreshRate: string) => void + onChangeAutoRefreshRate?: (enableAutoRefresh: boolean, refreshRate: string) => void +} + +const TIMEOUT_TO_UPDATE_REFRESH_TIME = 1_000 * MINUTE // once a minute + +const AutoRefresh = ({ + postfix, + loading, + displayText = true, + lastRefreshTime, + containerClassName = '', + testid = '', + turnOffAutoRefresh, + onRefresh, + onEnableAutoRefresh, + onChangeAutoRefreshRate, +}: Props) => { + let intervalText: NodeJS.Timeout + let timeoutRefresh: NodeJS.Timeout + + const [refreshMessage, setRefreshMessage] = useState(NOW) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const [refreshRate, setRefreshRate] = useState('') + const [refreshRateMessage, setRefreshRateMessage] = useState('') + const [enableAutoRefresh, setEnableAutoRefresh] = useState(false) + const [editingRate, setEditingRate] = useState(false) + + const onButtonClick = () => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen) + const closePopover = () => { + setEnableAutoRefresh(enableAutoRefresh) + setIsPopoverOpen(false) + } + + useEffect(() => { + const refreshRateStorage = localStorageService.get(BrowserStorageItem.autoRefreshRate + postfix) + || DEFAULT_REFRESH_RATE + + setRefreshRate(refreshRateStorage) + }, [postfix]) + + useEffect(() => { + if (turnOffAutoRefresh && enableAutoRefresh) { + setEnableAutoRefresh(false) + clearInterval(timeoutRefresh) + } + }, [turnOffAutoRefresh]) + + // update refresh label text + useEffect(() => { + const delta = getLastRefreshDelta(lastRefreshTime) + updateLastRefresh() + + intervalText = setInterval(() => { + if (document.hidden) return + + updateLastRefresh() + }, delta < DURATION_FIRST_REFRESH_TIME ? DURATION_FIRST_REFRESH_TIME : TIMEOUT_TO_UPDATE_REFRESH_TIME) + return () => clearInterval(intervalText) + }, [lastRefreshTime]) + + // refresh interval + useEffect(() => { + updateLastRefresh() + + if (enableAutoRefresh && !loading) { + timeoutRefresh = setInterval(() => { + if (document.hidden) return + + handleRefresh() + }, +refreshRate * 1_000) + } else { + clearInterval(timeoutRefresh) + } + + if (enableAutoRefresh) { + updateAutoRefreshText(refreshRate) + } + + return () => clearInterval(timeoutRefresh) + }, [enableAutoRefresh, refreshRate, loading, lastRefreshTime]) + + const getLastRefreshDelta = (time:Nullable) => (Date.now() - (time || 0)) / 1_000 + + const updateLastRefresh = () => { + const delta = getLastRefreshDelta(lastRefreshTime) + const text = getTextByRefreshTime(delta, lastRefreshTime ?? 0) + + lastRefreshTime && setRefreshMessage(text) + } + + const updateAutoRefreshText = (refreshRate: string) => { + enableAutoRefresh && setRefreshRateMessage( + // more than 1 minute + +refreshRate > MINUTE ? `${Math.floor(+refreshRate / MINUTE)} min` : `${refreshRate} s` + ) + } + + const handleApplyAutoRefreshRate = (initValue: string) => { + const value = +initValue >= MIN_REFRESH_RATE ? initValue : `${MIN_REFRESH_RATE}` + setRefreshRate(value) + setEditingRate(false) + localStorageService.set(BrowserStorageItem.autoRefreshRate + postfix, value) + onChangeAutoRefreshRate?.(enableAutoRefresh, value) + } + + const handleDeclineAutoRefreshRate = () => { + setEditingRate(false) + } + + const handleRefresh = () => { + onRefresh(enableAutoRefresh) + } + + const onChangeEnableAutoRefresh = (value: boolean) => { + setEnableAutoRefresh(value) + + onEnableAutoRefresh?.(value, refreshRate) + } + + return ( +
+ + {displayText && ( + {`${enableAutoRefresh ? 'Auto refresh:' : 'Last refresh:'}`} + )} + + {` ${enableAutoRefresh ? refreshRateMessage : refreshMessage}`} + + + + + + + + + )} + > +
+ onChangeEnableAutoRefresh(e.target.checked)} + className={styles.switchOption} + data-testid="auto-refresh-switch" + /> +
+
+
Refresh rate:
+ {!editingRate && ( + setEditingRate(true)} + data-testid="refresh-rate" + > + {`${refreshRate} s`} +
+
+ )} + {editingRate && ( + <> +
+ handleDeclineAutoRefreshRate()} + onApply={(value) => handleApplyAutoRefreshRate(value)} + /> +
+ {' s'} + + )} +
+ +
+ +
+ ) +} + +export default AutoRefresh diff --git a/redisinsight/ui/src/pages/browser/components/auto-refresh/index.ts b/redisinsight/ui/src/pages/browser/components/auto-refresh/index.ts new file mode 100644 index 0000000000..1b636f6ad1 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/auto-refresh/index.ts @@ -0,0 +1,5 @@ +import AutoRefresh from './AutoRefresh' + +export * from './AutoRefresh' + +export default AutoRefresh diff --git a/redisinsight/ui/src/pages/browser/components/auto-refresh/styles.module.scss b/redisinsight/ui/src/pages/browser/components/auto-refresh/styles.module.scss new file mode 100644 index 0000000000..52b11cbf01 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/auto-refresh/styles.module.scss @@ -0,0 +1,136 @@ +.container { + position: relative; + white-space: nowrap; +} + +.btn { + display: inline-block; + margin-top: 1px; + transition: transform 0.3s ease; + + svg { + width: 20px; + height: 20px; + } + + &.rolling svg { + color: var(--euiColorPrimary) !important; + } +} + +.time { + padding-right: 6px; + color: var(--euiTextSubduedColor) !important; +} + +.summary { + color: var(--euiColorMediumShade) !important; + vertical-align: middle; + font: normal normal normal 12px/18px "Graphik", sans-serif !important; + letter-spacing: -0.12px; +} + +.tooltip { + max-width: 372px !important; +} + +.switch { + padding-bottom: 16px; +} + +.popoverWrapper { + width: 240px; + height: 114px; + padding: 28px 18px !important; + background-color: var(--euiColorLightestShade) !important; + + .input { + display: inline-block; + width: 80px; + + input { + height: 30px !important; + border-radius: 0px !important; + background-color: var(--euiColorLightestShade) !important; + } + } +} + +.inputContainer { + height: 30px; + line-height: 30px; +} + +.inputLabel { + display: inline-block; + width: 80px; + font-size: 13px; + color: var(--euiTextSubduedColor) !important; +} + +.anchorBtn { + width: 18px !important; + height: 22px !important; + margin-top: 4px; + + &Open { + background-color: var(--browserComponentActive) !important; + border-bottom: 2px solid var(--euiColorPrimary) !important; + border-radius: 4px 4px 0 0 !important; + } + + svg { + width: 10px !important; + height: 10px !important; + } +} + +.switchOption { + :global(.euiSwitch__label) { + color: var(--euiTextSubduedColor) !important; + font-size: 13px !important; + } + + :global(.euiSwitch__button) { + width: 28px !important; + margin-right: 4px; + } +} + +.enable { + .time { + color: var(--euiColorPrimary) !important; + } +} + +.refreshRatePencil { + display: none; + right: 61px; + margin-bottom: 3px; + background-color: var(--browserTableRowEven); + width: 26px !important; + height: 28px !important; + padding: 5px; + position: absolute; + + svg { + margin-top: -16px !important; + } +} + +.refreshRateText { + display: inline-block; + width: 80px; + height: 30px; + border: 1px solid transparent; + cursor: pointer; + + &:hover { + padding-left: 5px; + border-color: var(--controlsBorderColor); + + .refreshRatePencil { + display: inline-block !important; + } + } +} diff --git a/redisinsight/ui/src/pages/browser/components/auto-refresh/utils.ts b/redisinsight/ui/src/pages/browser/components/auto-refresh/utils.ts new file mode 100644 index 0000000000..4f2d47efc8 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/auto-refresh/utils.ts @@ -0,0 +1,22 @@ +import { truncateNumberToFirstUnit } from 'uiSrc/utils' + +export const NOW = 'now' +export const MINUTE = 60 +export const DURATION_FIRST_REFRESH_TIME = 5 +export const DEFAULT_REFRESH_RATE = '5.0' + +export const getTextByRefreshTime = (delta: number, lastRefreshTime: number) => { + let text = '' + + if (delta > MINUTE) { + text = truncateNumberToFirstUnit((Date.now() - (lastRefreshTime || 0)) / 1_000) + } + if (delta < MINUTE) { + text = '< 1 min' + } + if (delta < DURATION_FIRST_REFRESH_TIME) { + text = NOW + } + + return text +} diff --git a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx index 1d1f621d79..9bdec81fd2 100644 --- a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx @@ -8,8 +8,8 @@ import { render, screen, } from 'uiSrc/utils/test-utils' -import { loadKeys, setFilter } from 'uiSrc/slices/keys' -import { connectedInstanceOverviewSelector } from 'uiSrc/slices/instances' +import { loadKeys, setFilter } from 'uiSrc/slices/browser/keys' +import { connectedInstanceOverviewSelector } from 'uiSrc/slices/instances/instances' import { KeyTypes } from 'uiSrc/constants' import { resetBrowserTree } from 'uiSrc/slices/app/context' import FilterKeyType from './FilterKeyType' @@ -19,7 +19,7 @@ let store: typeof mockedStore const filterSelectId = 'select-filter-key-type' const filterInfoId = 'filter-info-popover-icon' -jest.mock('uiSrc/slices/instances', () => ({ +jest.mock('uiSrc/slices/instances/instances', () => ({ connectedInstanceOverviewSelector: jest.fn().mockReturnValue({ version: '6.2.1', }), diff --git a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx index d73c0e2b39..2528f09ce3 100644 --- a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx +++ b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx @@ -11,8 +11,8 @@ import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' import { CommandsVersions } from 'uiSrc/constants/commandsVersions' -import { connectedInstanceOverviewSelector } from 'uiSrc/slices/instances' -import { fetchKeys, keysSelector, setFilter } from 'uiSrc/slices/keys' +import { connectedInstanceOverviewSelector } from 'uiSrc/slices/instances/instances' +import { fetchKeys, keysSelector, setFilter } from 'uiSrc/slices/browser/keys' import { isVersionHigherOrEquals } from 'uiSrc/utils' import HelpTexts from 'uiSrc/constants/help-texts' import { resetBrowserTree } from 'uiSrc/slices/app/context' diff --git a/redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts b/redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts index e57bf8bbb6..910df96e68 100644 --- a/redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts +++ b/redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts @@ -2,7 +2,6 @@ import { GROUP_TYPES_COLORS, KeyTypes, ModulesKeyTypes, - UnsupportedKeyTypes, } from 'uiSrc/constants' export const FILTER_KEY_TYPE_OPTIONS = [ @@ -38,8 +37,8 @@ export const FILTER_KEY_TYPE_OPTIONS = [ }, { text: 'STREAM', - value: UnsupportedKeyTypes.Stream, - color: GROUP_TYPES_COLORS[UnsupportedKeyTypes.Stream], + value: KeyTypes.Stream, + color: GROUP_TYPES_COLORS[KeyTypes.Stream], }, { text: 'GRAPH', diff --git a/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.spec.tsx b/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.spec.tsx index 4827b949e9..d1e4f8becc 100644 --- a/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.spec.tsx @@ -10,8 +10,8 @@ const fields = [ { field: 'field3', value: '5' }, ] -jest.mock('uiSrc/slices/hash', () => { - const defaultState = jest.requireActual('uiSrc/slices/hash').initialState +jest.mock('uiSrc/slices/browser/hash', () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/hash').initialState return ({ hashSelector: jest.fn().mockReturnValue(defaultState), updateHashValueStateSelector: jest.fn().mockReturnValue(defaultState.updateValue), diff --git a/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx b/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx index b2a1acbb6b..1ec20e4972 100644 --- a/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx +++ b/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx @@ -1,4 +1,4 @@ -import { EuiButtonIcon, EuiText, EuiToolTip } from '@elastic/eui' +import { EuiButtonIcon, EuiProgress, EuiText, EuiToolTip } from '@elastic/eui' import cx from 'classnames' import React, { useCallback, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' @@ -10,8 +10,8 @@ import { fetchMoreHashFields, updateHashValueStateSelector, updateHashFieldsAction, -} from 'uiSrc/slices/hash' -import { formatLongName, Nullable } from 'uiSrc/utils' +} from 'uiSrc/slices/browser/hash' +import { formatLongName, createDeleteFieldHeader, createDeleteFieldMessage, Nullable } from 'uiSrc/utils' import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent, getMatchType } from 'uiSrc/telemetry' import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' @@ -20,8 +20,8 @@ import { ITableColumn, } from 'uiSrc/components/virtual-table/interfaces' import { NoResultsFoundText } from 'uiSrc/constants/texts' -import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/keys' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/browser/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' import HelpTexts from 'uiSrc/constants/help-texts' import { @@ -81,8 +81,23 @@ const HashDetails = (props: Props) => { setDeleting(`${field + suffix}`) }, []) + const onSuccessRemoved = () => { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Hash, + numberOfRemoved: 1, + } + }) + } + const handleDeleteField = (field = '') => { - dispatch(deleteHashFields(key, [field])) + dispatch(deleteHashFields(key, [field], onSuccessRemoved)) closePopover() } @@ -244,8 +259,9 @@ const HashDetails = (props: Props) => { data-testid={`edit-hash-button-${field}`} /> { { footerOpened: isFooterOpen } )} > + {loading && ( + + )} { const [fields, setFields] = useState([{ ...INITIAL_HASH_FIELD_STATE }]) const { loading } = useSelector(updateHashValueStateSelector) const { name: selectedKey = '' } = useSelector(selectedKeyDataSelector) ?? { name: undefined } + const { viewType } = useSelector(keysSelector) + const { id: instanceId } = useSelector(connectedInstanceSelector) const lastAddedFieldName = useRef(null) useEffect(() => @@ -84,6 +89,22 @@ const AddHashFields = (props: Props) => { setFields(newState) } + const onSuccessAdded = () => { + onCancel() + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_ADDED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_ADDED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Hash, + numberOfAdded: fields.length, + } + }) + } + const handleFieldChange = (formField: string, id: number, value: any) => { const newState = fields.map((item) => { if (item.id === id) { @@ -105,7 +126,7 @@ const AddHashFields = (props: Props) => { value: item.fieldValue, })), } - dispatch(addHashFieldsAction(data, onCancel)) + dispatch(addHashFieldsAction(data, onSuccessAdded)) } const isClearDisabled = (item: IHashFieldState): boolean => diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-list-elements/AddListElements.tsx b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-list-elements/AddListElements.tsx index 87f4bf580f..79a49ead64 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-list-elements/AddListElements.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-list-elements/AddListElements.tsx @@ -13,8 +13,11 @@ import { EuiSuperSelectOption, } from '@elastic/eui' -import { selectedKeyDataSelector } from 'uiSrc/slices/keys' -import { insertListElementsAction } from 'uiSrc/slices/list' +import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/browser/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { insertListElementsAction } from 'uiSrc/slices/browser/list' +import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { KeyTypes } from 'uiSrc/constants' import { PushElementToListDto } from 'apiSrc/modules/browser/dto' import { AddListFormConfig as config } from '../../add-key/constants/fields-config' @@ -50,6 +53,8 @@ const AddListElements = (props: Props) => { const [element, setElement] = useState('') const [destination, setDestination] = useState(TAIL_DESTINATION) const { name: selectedKey = '' } = useSelector(selectedKeyDataSelector) ?? { name: undefined } + const { viewType } = useSelector(keysSelector) + const { id: instanceId } = useSelector(connectedInstanceSelector) const elementInput = useRef(null) @@ -60,13 +65,29 @@ const AddListElements = (props: Props) => { elementInput.current?.focus() }, []) + const onSuccessAdded = () => { + onCancel() + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_ADDED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_ADDED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.List, + numberOfAdded: 1, + } + }) + } + const submitData = (): void => { const data: PushElementToListDto = { keyName: selectedKey, element, destination, } - dispatch(insertListElementsAction(data, props.onCancel)) + dispatch(insertListElementsAction(data, onSuccessAdded)) } return ( diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-set-members/AddSetMembers.tsx b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-set-members/AddSetMembers.tsx index 20a1af5c23..32e12920d7 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-set-members/AddSetMembers.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-set-members/AddSetMembers.tsx @@ -11,8 +11,11 @@ import { EuiPanel, } from '@elastic/eui' -import { selectedKeyDataSelector } from 'uiSrc/slices/keys' -import { addSetMembersAction, setSelector } from 'uiSrc/slices/set' +import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/browser/keys' +import { addSetMembersAction, setSelector } from 'uiSrc/slices/browser/set' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { KeyTypes } from 'uiSrc/constants' +import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import AddItemsActions from '../../add-items-actions/AddItemsActions' import { AddZsetFormConfig as config } from '../../add-key/constants/fields-config' @@ -39,12 +42,30 @@ const AddSetMembers = (props: Props) => { const [members, setMembers] = useState([{ ...INITIAL_SET_MEMBER_STATE }]) const { loading } = useSelector(setSelector) const { name: selectedKey = '' } = useSelector(selectedKeyDataSelector) ?? { name: undefined } + const { viewType } = useSelector(keysSelector) + const { id: instanceId } = useSelector(connectedInstanceSelector) const lastAddedMemberName = useRef(null) useEffect(() => { lastAddedMemberName.current?.focus() }, [members.length]) + const onSuccessAdded = () => { + onCancel() + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_ADDED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_ADDED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Set, + numberOfAdded: members.length, + } + }) + } + const addMember = () => { const lastField = members[members.length - 1] const newState = [ @@ -90,7 +111,7 @@ const AddSetMembers = (props: Props) => { keyName: selectedKey, members: members.map((item) => item.name), } - dispatch(addSetMembersAction(data, onCancel)) + dispatch(addSetMembersAction(data, onSuccessAdded)) } const isClearDisabled = (item: ISetMemberState): boolean => members.length === 1 && !item.name.length diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx new file mode 100644 index 0000000000..6cf4488aa0 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/AddStreamEntries.tsx @@ -0,0 +1,157 @@ +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTextColor } from '@elastic/eui' +import cx from 'classnames' +import { keyBy, mapValues, toNumber } from 'lodash' +import React, { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { entryIdRegex } from 'uiSrc/utils' +import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/browser/keys' +import { addNewEntriesAction, streamDataSelector } from 'uiSrc/slices/browser/stream' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { AddStreamFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' +import { INITIAL_STREAM_FIELD_STATE } from 'uiSrc/pages/browser/components/add-key/AddKeyStream/AddKeyStream' +import { StreamEntryFields } from 'uiSrc/pages/browser/components/key-details-add-items' +import { KeyTypes } from 'uiSrc/constants' +import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { AddStreamEntriesDto } from 'apiSrc/modules/browser/dto/stream.dto' + +import styles from './styles.module.scss' + +export interface Props { + onCancel: (isCancelled?: boolean) => void +} + +const AddStreamEntries = (props: Props) => { + const { onCancel } = props + const { lastEntry } = useSelector(streamDataSelector) + const { name: keyName = '' } = useSelector(selectedKeyDataSelector) ?? { name: undefined } + const { viewType } = useSelector(keysSelector) + const { id: instanceId } = useSelector(connectedInstanceSelector) + + const [entryID, setEntryID] = useState('*') + const [entryIdError, setEntryIdError] = useState('') + const [fields, setFields] = useState([{ ...INITIAL_STREAM_FIELD_STATE }]) + const [isFormValid, setIsFormValid] = useState(false) + + const dispatch = useDispatch() + + useEffect(() => { + const isValid = !entryIdError + setIsFormValid(isValid) + }, [fields, entryIdError]) + + useEffect(() => { + validateEntryID() + }, [entryID]) + + const validateEntryID = () => { + if (!entryIdRegex.test(entryID)) { + setEntryIdError(`${config.entryId.name} format is incorrect`) + return + } + + if (!lastEntry?.id) { + setEntryIdError('') + return + } + + if (entryID === '*') { + setEntryIdError('') + return + } + + const [lastIdTimestamp, lastId] = lastEntry.id?.split('-') + const [idTimestamp, id] = entryID?.split('-') + + if (toNumber(idTimestamp) > toNumber(lastIdTimestamp)) { + setEntryIdError('') + return + } + + if (toNumber(lastIdTimestamp) === toNumber(idTimestamp) && (id === '*' || toNumber(id) > toNumber(lastId))) { + setEntryIdError('') + return + } + setEntryIdError('Must be greater than the last ID') + } + + const onSuccessAdded = () => { + onCancel() + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_ADDED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_ADDED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Stream, + numberOfAdded: fields.length, + } + }) + } + + const submitData = (): void => { + if (isFormValid) { + const data: AddStreamEntriesDto = { + keyName, + entries: [{ + id: entryID, + fields: mapValues(keyBy(fields, 'fieldName'), 'fieldValue') + }] + } + dispatch(addNewEntriesAction(data, onSuccessAdded)) + } + } + + return ( + <> + + + + + + +
+ onCancel(true)} data-testid="cancel-members-btn"> + Cancel + +
+
+ +
+ + Save + +
+
+
+
+ + ) +} + +export default AddStreamEntries diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx new file mode 100644 index 0000000000..6b3dacb18e --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/StreamEntryFields/StreamEntryFields.tsx @@ -0,0 +1,223 @@ +import React, { ChangeEvent, useEffect, useRef } from 'react' +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiSpacer, + EuiToolTip +} from '@elastic/eui' +import cx from 'classnames' +import AddItemsActions from 'uiSrc/pages/browser/components/add-items-actions/AddItemsActions' +import { validateEntryId } from 'uiSrc/utils' +import { INITIAL_STREAM_FIELD_STATE } from 'uiSrc/pages/browser/components/add-key/AddKeyStream/AddKeyStream' +import { AddStreamFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' + +import styles from '../styles.module.scss' + +export interface Props { + compressed?: boolean + entryIdError?: string + clearEntryIdError?: () => void + entryID: string + setEntryID: React.Dispatch> + fields: any[] + setFields: React.Dispatch> + handleBlurEntryID?: () => void +} + +const MIN_ENTRY_ID_VALUE = '0-1' + +const StreamEntryFields = (props: Props) => { + const { + compressed, + entryID, + setEntryID, + entryIdError, + fields, + setFields, + } = props + + const [isEntryIdFocused, setIsEntryIdFocused] = React.useState(false) + const prevCountFields = useRef(0) + const lastAddedFieldName = useRef(null) + const entryIdRef = useRef(null) + + const isClearDisabled = (item: any): boolean => + fields.length === 1 && !(item.fieldName.length || item.fieldValue.length) + + useEffect(() => { + if (prevCountFields.current !== 0 && prevCountFields.current < fields.length) { + lastAddedFieldName.current?.focus() + } + prevCountFields.current = fields.length + }, [fields.length]) + + const addField = () => { + const lastField = fields[fields.length - 1] + const newState = [ + ...fields, + { + ...INITIAL_STREAM_FIELD_STATE, + id: lastField.id + 1 + } + ] + setFields(newState) + } + + const removeField = (id: number) => { + const newState = fields.filter((item) => item.id !== id) + setFields(newState) + } + + const clearFieldsValues = (id: number) => { + const newState = fields.map((item) => (item.id === id + ? { + ...item, + fieldName: '', + fieldValue: '' + } : item)) + setFields(newState) + } + + const handleEntryIdChange = (e: ChangeEvent) => { + setEntryID(validateEntryId(e.target.value)) + } + + const handleFieldChange = (formField: string, id: number, value: any) => { + const newState = fields.map((item) => { + if (item.id === id) { + return { + ...item, + [formField]: value + } + } + return item + }) + setFields(newState) + } + + const onEntryIdBlur = (e: React.FocusEvent) => { + e.target.value === '0-0' && setEntryID(MIN_ENTRY_ID_VALUE) + setIsEntryIdFocused(false) + } + + const showEntryError = !isEntryIdFocused && entryIdError + + return ( +
+
+ + setIsEntryIdFocused(true)} + append={( + + ID must be a timestamp and sequence number greater than the last ID. + + Otherwise, type * to auto-generate ID based on the database current time. + + )} + > + + + )} + isInvalid={!!entryIdError} + autoComplete="off" + data-testid={config.entryId.id} + /> + + {!showEntryError && Timestamp - Sequence Number or *} + {showEntryError && {entryIdError}} +
+ +
+
+ { + fields.map((item, index) => ( + + + + + + + ) => + handleFieldChange( + 'fieldName', + item.id, + e.target.value + )} + inputRef={index === fields.length - 1 ? lastAddedFieldName : null} + autoComplete="off" + data-testid="field-name" + /> + + + + + ) => + handleFieldChange( + 'fieldValue', + item.id, + e.target.value + )} + autoComplete="off" + data-testid="field-value" + /> + + + + + + + + )) + } +
+
+
+ ) +} + +export default StreamEntryFields diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/index.ts b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/index.ts new file mode 100644 index 0000000000..546779829f --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/index.ts @@ -0,0 +1,5 @@ +import StreamEntryFields from './StreamEntryFields/StreamEntryFields' +import AddStreamEntries from './AddStreamEntries' + +export { StreamEntryFields } +export default AddStreamEntries diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/styles.module.scss new file mode 100644 index 0000000000..95e4a5e4fd --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-entity/styles.module.scss @@ -0,0 +1,113 @@ +@import '@elastic/eui/src/global_styling/mixins/helpers'; +@import '@elastic/eui/src/global_styling/variables/index'; + +$rowHeight: 43px; + +.content { + display: flex; + flex-direction: column; + width: 100%; + border: none !important; + border-top: 1px solid var(--euiColorPrimary); + padding: 12px 20px; + max-height: 234px; + scroll-padding-bottom: 30px; +} + +.container { + display: flex; + flex-direction: column; + + &.compressed { + flex-direction: row; + + .entryIdContainer { + width: 190px; + + :global(.euiFormControlLayout--group) { + border-right: 0; + } + } + + .fieldsWrapper { + width: auto; + } + + .valueItemWrapper { + flex-grow: 1.5; + } + } + + .entryIdContainer { + width: 50%; + flex-shrink: 0; + + .timestampText { + display: inline-block; + color: var(--euiColorMediumShade); + font: normal normal normal 12px/18px Graphik; + margin-top: 6px; + padding-right: 6px; + } + } + + .fieldsWrapper { + flex-grow: 1; + flex-shrink: 0; + width: 100%; + margin-top: 12px; + } + + + //.fieldsContainer { + // @include euiScrollBar; + // max-height: calc(4*#{$rowHeight}); + // overflow-x: hidden; + //} +} + + +.entryIdTooltip { + max-width: 275px !important; +} + + +.deleteBtn { + position: absolute; + display: none; + top: 50%; + right: 8px; + transform: translateY(-50%); +} + +.row { + position: relative; + margin-top: 8px; + margin-bottom: 16px; + + &.compressed { + margin-bottom: 0; + } + + &:hover { + .deleteBtn { + display: block; + } + + .fieldValue { + padding-right: 40px; + } + } +} + +.addRowBtn { + margin-top: 12px; +} + +.error { + display: inline-block; + color: var(--euiColorDangerText); + font: normal normal normal 12px/18px Graphik; + margin-top: 6px; + padding-right: 6px; +} diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx index 105b4d70cc..280578253f 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx @@ -14,12 +14,12 @@ import { import { validateScoreNumber } from 'uiSrc/utils' import { isNaNConvertedString } from 'uiSrc/utils/numbers' -import { selectedKeyDataSelector } from 'uiSrc/slices/keys' +import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' import { fetchAddZSetMembers, resetUpdateScore, updateZsetScoreStateSelector, -} from 'uiSrc/slices/zset' +} from 'uiSrc/slices/browser/zset' import AddItemsActions from '../../add-items-actions/AddItemsActions' import { AddZsetFormConfig as config } from '../../add-key/constants/fields-config' diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/index.ts b/redisinsight/ui/src/pages/browser/components/key-details-add-items/index.ts new file mode 100644 index 0000000000..a37d080cac --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/index.ts @@ -0,0 +1,14 @@ +import AddHashFields from './add-hash-fields/AddHashFields' +import AddListElements from './add-list-elements/AddListElements' +import AddSetMembers from './add-set-members/AddSetMembers' +import AddStreamEntries, { StreamEntryFields } from './add-stream-entity' +import AddZsetMembers from './add-zset-members/AddZsetMembers' + +export { + AddHashFields, + AddListElements, + AddSetMembers, + AddStreamEntries, + StreamEntryFields, + AddZsetMembers +} diff --git a/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.spec.tsx index bc1cb2362b..3357292b32 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.spec.tsx @@ -9,6 +9,13 @@ const KEY_INPUT_TEST_ID = 'edit-key-input' const KEY_BTN_TEST_ID = 'edit-key-btn' const TTL_INPUT_TEST_ID = 'edit-ttl-input' +jest.mock('uiSrc/slices/browser/keys', () => ({ + ...jest.requireActual('uiSrc/slices/browser/keys'), + selectedKeyDataSelector: jest.fn().mockReturnValue({ + name: 'test', + }), +})) + describe('KeyDetailsHeader', () => { global.navigator.clipboard = { writeText: jest.fn() @@ -70,7 +77,7 @@ describe('KeyDetailsHeader', () => { const onRefresh = jest.fn() render() - fireEvent.click(screen.getByLabelText(/Refresh key/i)) + fireEvent.click(screen.getByTestId('refresh-key-btn')) expect(onRefresh).toBeCalled() }) diff --git a/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx b/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx index 6a808b1b40..742140a210 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx @@ -13,43 +13,54 @@ import { } from '@elastic/eui' import React, { ChangeEvent, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' +import { isNull } from 'lodash' import cx from 'classnames' -import { formatDistanceToNow } from 'date-fns' +import AutoSizer from 'react-virtualized-auto-sizer' import { GroupBadge } from 'uiSrc/components' -import { KeyTypes, KEY_TYPES_ACTIONS, LENGTH_NAMING_BY_TYPE } from 'uiSrc/constants' -import { selectedKeyDataSelector, selectedKeySelector, keysSelector } from 'uiSrc/slices/keys' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' -import { KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { KeyTypes, KEY_TYPES_ACTIONS, LENGTH_NAMING_BY_TYPE, ModulesKeyTypes } from 'uiSrc/constants' +import { selectedKeyDataSelector, selectedKeySelector, keysSelector } from 'uiSrc/slices/browser/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { formatBytes, formatNameShort, MAX_TTL_NUMBER, replaceSpaces, validateTTLNumber } from 'uiSrc/utils' -import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent} from 'uiSrc/telemetry' +import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent } from 'uiSrc/telemetry' import { AddCommonFieldsFormConfig } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' +import AutoRefresh from '../auto-refresh' import styles from './styles.module.scss' export interface Props { - keyType: KeyTypes; - onClose: (key: string) => void; - onRefresh: (key: string, type: KeyTypes) => void; - onDelete: (key: string, type: string) => void; - onEditTTL: (key: string, ttl: number) => void; - onEditKey: (key: string, newKey: string, onFailure?: () => void) => void; - onAddItem?: () => void; - onEditItem?: () => void; - onRemoveItem?: () => void; + keyType: KeyTypes | ModulesKeyTypes + onClose: (key: string) => void + onRefresh: (key: string, type: KeyTypes | ModulesKeyTypes) => void + onDelete: (key: string, type: string) => void + onEditTTL: (key: string, ttl: number) => void + onEditKey: (key: string, newKey: string, onFailure?: () => void) => void + onAddItem?: () => void + onEditItem?: () => void + onRemoveItem?: () => void + isFullScreen: boolean + arePanelsCollapsed: boolean + onToggleFullScreen: () => void } const COPY_KEY_NAME_ICON = 'copyKeyNameIcon' const initialKeyInfo = { ttl: -1, - name: '', + name: null, type: KeyTypes.String, size: 1, length: 0, } +const PADDING_WRAPPER_SIZE = 36 +const HIDE_LAST_REFRESH = 750 - PADDING_WRAPPER_SIZE +const MIDDLE_SCREEN_RESOLUTION = 640 - PADDING_WRAPPER_SIZE + const KeyDetailsHeader = ({ + isFullScreen, + arePanelsCollapsed, + onToggleFullScreen = () => {}, onRefresh, onClose, onDelete, @@ -66,7 +77,6 @@ const KeyDetailsHeader = ({ const { viewType } = useSelector(keysSelector) const [isPopoverDeleteOpen, setIsPopoverDeleteOpen] = useState(false) - const [lastRefreshMessage, setLastRefreshMessage] = useState('') const [ttl, setTTL] = useState(`${ttlProp}`) const [ttlIsEditing, setTTLIsEditing] = useState(false) @@ -81,11 +91,7 @@ const KeyDetailsHeader = ({ setTTL(`${ttlProp}`) }, [keyProp, ttlProp]) - useEffect(() => { - updateLastRefresh() - }, [lastRefreshTime]) - - const keyNameRef = useRef(null) + const keyNameRef = useRef(null) const tooltipContent = formatNameShort(keyProp) @@ -109,7 +115,7 @@ const KeyDetailsHeader = ({ setKeyIsEditing(false) setKeyIsHovering(false) - if (keyProp !== key) { + if (keyProp !== key && !isNull(keyProp)) { onEditKey(keyProp, key, () => setKey(keyProp)) } } @@ -149,7 +155,7 @@ const KeyDetailsHeader = ({ event: any, text = '', keyInputIsEditing: boolean, - keyNameInputRef: React.MutableRefObject + keyNameInputRef: React.RefObject ) => { navigator.clipboard.writeText(text) @@ -172,18 +178,20 @@ const KeyDetailsHeader = ({ }) } - const handleRefreshKey = () => { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_DETAILS_REFRESH_CLICKED, - TelemetryEvent.TREE_VIEW_KEY_DETAILS_REFRESH_CLICKED - ), - eventData: { - databaseId: instanceId, - keyType: type - } - }) + const handleRefreshKey = (enableAutoRefresh: boolean) => { + if (!enableAutoRefresh) { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_DETAILS_REFRESH_CLICKED, + TelemetryEvent.TREE_VIEW_KEY_DETAILS_REFRESH_CLICKED + ), + eventData: { + databaseId: instanceId, + keyType: type + } + }) + } onRefresh(key, type) } @@ -228,13 +236,94 @@ const KeyDetailsHeader = ({ const appendTTLEditing = () => (!ttlIsEditing ? : '') - const updateLastRefresh = () => { - setLastRefreshMessage( - lastRefreshTime - ? `${formatDistanceToNow(lastRefreshTime, { addSuffix: true })}` - : 'Refresh' - ) - } + const KeySize = (width: number) => ( + + + + {formatBytes(size, 3)} + + )} + > + <> + {width > MIDDLE_SCREEN_RESOLUTION && 'Key Size: '} + {formatBytes(size, 0)} + + + + + ) + + const Actions = (width: number) => ( + <> + {'addItems' in KEY_TYPES_ACTIONS[keyType] && ( + MIDDLE_SCREEN_RESOLUTION ? '' : KEY_TYPES_ACTIONS[keyType].addItems?.name} + position="left" + anchorClassName={cx(styles.actionBtn, { [styles.withText]: width > MIDDLE_SCREEN_RESOLUTION })} + > + <> + {width > MIDDLE_SCREEN_RESOLUTION ? ( + + {KEY_TYPES_ACTIONS[keyType].addItems?.name} + + ) : ( + + )} + + + )} + {'removeItems' in KEY_TYPES_ACTIONS[keyType] && ( + + + + )} + {'editItem' in KEY_TYPES_ACTIONS[keyType] && ( +
+ +
+ )} + + ) return (
@@ -243,356 +332,295 @@ const KeyDetailsHeader = ({
) : ( - <> - - - - - - {keyIsEditing || keyIsHovering ? ( - + {({ width }) => ( +
+ + + + + - - - <> - applyEditKey()} - onDecline={(event) => cancelEditKey(event)} - viewChildrenMode={!keyIsEditing} - isLoading={loading} - declineOnUnmount={false} + + - - -

{key}

- -
- {keyIsHovering && ( + <> + applyEditKey()} + onDecline={(event) => cancelEditKey(event)} + viewChildrenMode={!keyIsEditing} + isLoading={loading} + declineOnUnmount={false} + > + + +

{key}

+ + + {keyIsHovering && ( + + + handleCopy(event, key, keyIsEditing, keyNameRef)} + data-testid="copy-key-name-btn" + /> + + )} +
+ + ) : ( + + + {replaceSpaces(keyProp?.substring(0, 200))} + + + )} +
+ + {!arePanelsCollapsed && ( + - handleCopy(event, key, keyIsEditing, keyNameRef)} - data-testid="copy-key-name-btn" + aria-label="Open full screen" + onClick={onToggleFullScreen} + data-testid="toggle-full-screen" /> - )} - - ) : ( - - - {replaceSpaces(keyProp.substring(0, 200))} - - - )} - - - - + + onClose(keyProp)} + data-testid="close-key-btn" + /> + + +
+ - onClose(keyProp)} - data-testid="close-key-btn" - /> - - - - - { - size && ( + {size && KeySize(width)} - - {formatBytes(size, 3)} - - )} - > - <>{formatBytes(size, 0)} - + {LENGTH_NAMING_BY_TYPE[type] ?? 'Length'} + {': '} + {length ?? '-'} - ) - } - - - {LENGTH_NAMING_BY_TYPE[type] ?? 'Length'} - {' '} - ( - {length ?? '-'} - ) - - - - {ttlIsEditing || ttlIsHovering ? ( - - + {ttlIsEditing || ttlIsHovering ? ( + + + + TTL: + + + + applyEditTTL()} + onDecline={(event) => cancelEditTTl(event)} + viewChildrenMode={!ttlIsEditing} + isLoading={loading} + declineOnUnmount={false} + > + + + + + ) : ( TTL: + + {ttl === '-1' ? 'No limit' : ttl} + - - - applyEditTTL()} - onDecline={(event) => cancelEditTTl(event)} - viewChildrenMode={!ttlIsEditing} - isLoading={loading} - declineOnUnmount={false} - > - - - - - ) : ( - - TTL: - - {ttl === '-1' ? 'No limit' : ttl} - - - )} - - -
- { - keyType && KEY_TYPES_ACTIONS[keyType] && ('removeItems' in KEY_TYPES_ACTIONS[keyType]) && ( - - - - ) - } - { - keyType && KEY_TYPES_ACTIONS[keyType] && ('addItems' in KEY_TYPES_ACTIONS[keyType]) && ( - - - - ) - } - { - keyType && KEY_TYPES_ACTIONS[keyType] && ('editItem' in KEY_TYPES_ACTIONS[keyType]) && ( -
+ )} + + +
+ HIDE_LAST_REFRESH} + onRefresh={handleRefreshKey} + containerClassName={styles.actionBtn} + testid="refresh-key-btn" + /> + {(keyType && KEY_TYPES_ACTIONS[keyType]) && Actions(width)} + + + )} + > +
+ +

+ {tooltipContent} +

+ + will be deleted. + +
+
+ onDelete(keyProp, type)} + className={styles.popoverDeleteBtn} + data-testid="delete-key-confirm-btn" + > + Delete + +
- ) - } - - - - - - - )} - > -
- -

- {tooltipContent} -

- - will be deleted. - -
-
- onDelete(keyProp, type)} - className={styles.popoverDeleteBtn} - data-testid="delete-key-confirm-btn" - > - Delete - -
+
-
-
-
- - + + +
+ )} + )}
) diff --git a/redisinsight/ui/src/pages/browser/components/key-details-header/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-details-header/styles.module.scss index ea60d66f3f..6a30c50dd7 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-header/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/key-details-header/styles.module.scss @@ -3,7 +3,7 @@ .key-details-header { .euiFieldText--compressed, .euiFormControlLayout--compressed { - height: 31px !important; + height: 29px !important; } .euiFormControlLayout { @@ -33,11 +33,10 @@ } .container { - padding: 12px 12px 12px 16px; + min-height: 108px; + padding: 18px 18px 12px 18px; border-bottom: 1px solid var(--euiColorLightShade); min-width: 100%; - width: 1px; - min-height: 84px; position: relative; } @@ -50,21 +49,16 @@ line-height: 31px !important; } -.keyType { - padding-top: 6px; - padding-right: 5px; -} - .flexItemTTL { - width: 180px; - min-width: 180px; + width: 152px; + min-width: 152px; } .ttlInput { - min-width: 110px; + min-width: 106px; font-size: 13px !important; &.editing { - width: 150px; + width: 124px; } } @@ -87,9 +81,6 @@ padding-left: 11px; } -.groupSecondLine { - margin-bottom: -20px !important; -} .controlsTTL, .controlsKey { @@ -155,16 +146,16 @@ .copyKey { position: absolute; padding-left: 7px; - padding-top: 2px; + padding-top: 4px; right: 0; - height: 25px; + height: 31px; width: 25px; } .subtitleActionBtns { display: flex; justify-content: flex-end; - padding-top: 4px; + align-items: center; right: 13px; } @@ -183,10 +174,32 @@ word-break: break-all; } -.refreshKeyTooltip { - padding-right: 7%; +.actionBtn { + margin-right: 12px; + + &.withText { + color: var(--euiTextSubduedColor) !important; + :global(.euiButton__text) { + font: normal normal normal 12px/18px Graphik !important; + } + } } .capitalize { text-transform: capitalize; } + +.groupSecondLine { + margin-top: 4px !important; +} + +.refreshSummary { + color: var(--euiColorMediumShade) !important; + font: normal normal normal 12px/18px Graphik; + padding-bottom: 2px; + margin-right: 4px; +} + +.refreshTime { + color: var(--euiTextSubduedColor) !important; +} diff --git a/redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx index e55a56040d..a44c7e1e69 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx @@ -2,7 +2,7 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' -import { connectedInstanceOverviewSelector } from 'uiSrc/slices/instances' +import { connectedInstanceOverviewSelector } from 'uiSrc/slices/instances/instances' import RemoveListElements, { Props } from './RemoveListElements' import { HEAD_DESTINATION } from '../../key-details-add-items/add-list-elements/AddListElements' @@ -11,7 +11,7 @@ const COUNT_INPUT = 'count-input' const mockedProps = mock() -jest.mock('uiSrc/slices/instances', () => ({ +jest.mock('uiSrc/slices/instances/instances', () => ({ connectedInstanceOverviewSelector: jest.fn().mockReturnValue({ version: '6.2.1', }), diff --git a/redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx b/redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx index 2023c67a1c..08e8cc7263 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx @@ -24,9 +24,9 @@ import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent } from 'uiS import HelpTexts from 'uiSrc/constants/help-texts' import { CommandsVersions } from 'uiSrc/constants/commandsVersions' -import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/keys' -import { deleteListElementsAction } from 'uiSrc/slices/list' -import { connectedInstanceOverviewSelector, connectedInstanceSelector } from 'uiSrc/slices/instances' +import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/browser/keys' +import { deleteListElementsAction } from 'uiSrc/slices/browser/list' +import { connectedInstanceOverviewSelector, connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { DeleteListElementsDto } from 'apiSrc/modules/browser/dto' @@ -119,6 +119,22 @@ const RemoveListElements = (props: Props) => { setIsPopoverOpen(false) } + const onSuccessRemoved = () => { + onCancel() + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.List, + numberOfRemoved: toNumber(count), + } + }) + } + const submitData = (): void => { const data: DeleteListElementsDto = { keyName: selectedKey, @@ -126,7 +142,7 @@ const RemoveListElements = (props: Props) => { destination, } closePopover() - dispatch(deleteListElementsAction(data, props.onCancel)) + dispatch(deleteListElementsAction(data, onSuccessRemoved)) } const RemoveButton = () => ( diff --git a/redisinsight/ui/src/pages/browser/components/key-details/KeyDetails/KeyDetails.tsx b/redisinsight/ui/src/pages/browser/components/key-details/KeyDetails/KeyDetails.tsx index 07cc5f2221..d45680cf5c 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details/KeyDetails/KeyDetails.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details/KeyDetails/KeyDetails.tsx @@ -1,25 +1,31 @@ import React, { useEffect, useState } from 'react' import { EuiText, - EuiFlexGroup + EuiFlexGroup, + EuiButtonIcon, + EuiToolTip } from '@elastic/eui' import { isNull } from 'lodash' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' +import { + AddStreamEntries, + AddListElements, + AddSetMembers, + AddZsetMembers, + AddHashFields +} from 'uiSrc/pages/browser/components/key-details-add-items' import { selectedKeyDataSelector, selectedKeySelector, keysSelector, -} from 'uiSrc/slices/keys' +} from 'uiSrc/slices/browser/keys' +import { cleanRangeFilter } from 'uiSrc/slices/browser/stream' import { KeyTypes, ModulesKeyTypes, MODULES_KEY_TYPES_NAMES } from 'uiSrc/constants' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' -import { KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent } from 'uiSrc/telemetry' -import AddHashFields from '../../key-details-add-items/add-hash-fields/AddHashFields' -import AddZsetMembers from '../../key-details-add-items/add-zset-members/AddZsetMembers' -import AddSetMembers from '../../key-details-add-items/add-set-members/AddSetMembers' -import AddListElements from '../../key-details-add-items/add-list-elements/AddListElements' + import KeyDetailsHeader from '../../key-details-header/KeyDetailsHeader' import ZSetDetails from '../../zset-details/ZSetDetails' import StringDetails from '../../string-details/StringDetails' @@ -27,6 +33,7 @@ import SetDetails from '../../set-details/SetDetails' import HashDetails from '../../hash-details/HashDetails' import ListDetails from '../../list-details/ListDetails' import RejsonDetailsWrapper from '../../rejson-details/RejsonDetailsWrapper' +import StreamDetailsWrapper from '../../stream-details' import RemoveListElements from '../../key-details-remove-items/remove-list-elements/RemoveListElements' import UnsupportedTypeDetails from '../../unsupported-type-details/UnsupportedTypeDetails' import ModulesTypeDetails from '../../modules-type-details/ModulesTypeDetails' @@ -34,14 +41,19 @@ import ModulesTypeDetails from '../../modules-type-details/ModulesTypeDetails' import styles from '../styles.module.scss' export interface Props { - onClose: (key: string) => void; - onRefresh: (key: string, type: KeyTypes) => void; - onDelete: (key: string) => void; - onEditTTL: (key: string, ttl: number) => void; - onEditKey: (key: string, newKey: string, onFailure?: () => void) => void; + isFullScreen: boolean + arePanelsCollapsed: boolean + onToggleFullScreen: () => void + onClose: (key: string) => void + onClosePanel: () => void + onRefresh: (key: string, type: KeyTypes) => void + onDelete: (key: string, type: string) => void + onEditTTL: (key: string, ttl: number) => void + onEditKey: (key: string, newKey: string, onFailure?: () => void) => void } const KeyDetails = ({ ...props }: Props) => { + const { onClosePanel } = props const { loading, error = '', data } = useSelector(selectedKeySelector) const { type: selectedKeyType, name: selectedKey } = useSelector(selectedKeyDataSelector) ?? { type: KeyTypes.String, @@ -53,9 +65,12 @@ const KeyDetails = ({ ...props }: Props) => { const [isRemoveItemPanelOpen, setIsRemoveItemPanelOpen] = useState(false) const [editItem, setEditItem] = useState(false) + const dispatch = useDispatch() + useEffect(() => { - // Close 'Add Item Panel' on change selected key + // Close 'Add Item Panel' and remove stream range on change selected key closeAddItemPanel() + dispatch(cleanRangeFilter()) }, [selectedKey]) const openAddItemPanel = () => { @@ -100,25 +115,54 @@ const KeyDetails = ({ ...props }: Props) => { setIsRemoveItemPanelOpen(false) } + const TypeDetails: any = { + [KeyTypes.ZSet]: , + [KeyTypes.Set]: , + [KeyTypes.String]: ( + setEditItem(isEdit)} + /> + ), + [KeyTypes.Hash]: , + [KeyTypes.List]: , + [KeyTypes.ReJSON]: , + [KeyTypes.Stream]: , + } + return (
<> - {!isKeySelected ? ( -
- -

- {error - || 'Select the key from the list on the left to see the details of the key.'} -

-
-
+ {!isKeySelected && !loading ? ( + <> + + + + +
+ +

+ {error + || 'Select the key from the list on the left to see the details of the key.'} +

+
+
+ ) : (
{
{!loading && (
- {selectedKeyType === KeyTypes.ZSet && ( - - )} - {selectedKeyType === KeyTypes.Set && ( - - )} - {selectedKeyType === KeyTypes.String && ( - setEditItem(isEdit)} - /> - )} - {selectedKeyType === KeyTypes.Hash && ( - - )} - {selectedKeyType === KeyTypes.List && ( - - )} - {selectedKeyType === KeyTypes.ReJSON && ( - - )} + {(selectedKeyType && selectedKeyType in TypeDetails) && TypeDetails[selectedKeyType]} {(Object.values(ModulesKeyTypes).includes(selectedKeyType)) && ( @@ -186,6 +202,9 @@ const KeyDetails = ({ ...props }: Props) => { {selectedKeyType === KeyTypes.List && ( )} + {selectedKeyType === KeyTypes.Stream && ( + + )}
)} {isRemoveItemPanelOpen && ( diff --git a/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.spec.tsx index 8fa52c959d..8950843797 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.spec.tsx @@ -2,19 +2,7 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' import { KeyTypes } from 'uiSrc/constants' -import { refreshHashFieldsAction } from 'uiSrc/slices/hash' -import { refreshZsetMembersAction } from 'uiSrc/slices/zset' -import { refreshSetMembersAction } from 'uiSrc/slices/set' -import { refreshListElementsAction } from 'uiSrc/slices/list' -import { resetStringValue } from 'uiSrc/slices/string' -import { - deleteKeyAction, - editKey, - editKeyTTL, - fetchKeyInfo, - refreshKeyInfoAction, - selectedKeySelector -} from 'uiSrc/slices/keys' + import KeyDetails, { Props as KeyDetailsProps } from './KeyDetails/KeyDetails' import KeyDetailsWrapper, { Props } from './KeyDetailsWrapper' @@ -59,12 +47,12 @@ jest.mock('./KeyDetails/KeyDetails', () => ({ default: jest.fn(), })) -// jest.mock('uiSrc/slices/hash') -// jest.mock('uiSrc/slices/zset') -// jest.mock('uiSrc/slices/string') -// jest.mock('uiSrc/slices/set') -// jest.mock('uiSrc/slices/list') -// jest.mock('uiSrc/slices/keys') +// jest.mock('uiSrc/slices/browser/hash') +// jest.mock('uiSrc/slices/browser/zset') +// jest.mock('uiSrc/slices/browser/string') +// jest.mock('uiSrc/slices/browser/set') +// jest.mock('uiSrc/slices/browser/list') +// jest.mock('uiSrc/slices/browser/keys') describe('KeyDetailsWrapper', () => { beforeAll(() => { diff --git a/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.tsx b/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.tsx index 1a323b0880..2986d963af 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.tsx @@ -1,32 +1,45 @@ import React, { useEffect } from 'react' -import { useDispatch, useSelector } from 'react-redux' +import { useDispatch } from 'react-redux' import { deleteKeyAction, editKey, editKeyTTL, fetchKeyInfo, refreshKeyInfoAction, - selectedKeySelector, -} from 'uiSrc/slices/keys' -import { KeyTypes } from 'uiSrc/constants' -import { refreshHashFieldsAction } from 'uiSrc/slices/hash' -import { refreshZsetMembersAction } from 'uiSrc/slices/zset' -import { resetStringValue } from 'uiSrc/slices/string' -import { refreshSetMembersAction } from 'uiSrc/slices/set' -import { refreshListElementsAction } from 'uiSrc/slices/list' + toggleBrowserFullScreen, +} from 'uiSrc/slices/browser/keys' +import { KeyTypes, ModulesKeyTypes } from 'uiSrc/constants' +import { refreshHashFieldsAction } from 'uiSrc/slices/browser/hash' +import { refreshZsetMembersAction } from 'uiSrc/slices/browser/zset' +import { fetchString, resetStringValue } from 'uiSrc/slices/browser/string' +import { refreshSetMembersAction } from 'uiSrc/slices/browser/set' +import { refreshListElementsAction } from 'uiSrc/slices/browser/list' +import { fetchReJSON } from 'uiSrc/slices/browser/rejson' +import { refreshStreamEntries } from 'uiSrc/slices/browser/stream' import KeyDetails from './KeyDetails/KeyDetails' export interface Props { - onCloseKey: () => void; - onEditKey: (key: string, newKey: string) => void; - onDeleteKey: () => void; - keyProp: string | null; + isFullScreen: boolean + arePanelsCollapsed: boolean + onToggleFullScreen: () => void + onCloseKey: () => void + onEditKey: (key: string, newKey: string) => void + onDeleteKey: () => void + keyProp: string | null } -const KeyDetailsWrapper = ({ onCloseKey, onEditKey, onDeleteKey, keyProp }: Props) => { - const dispatch = useDispatch() +const KeyDetailsWrapper = (props: Props) => { + const { + isFullScreen, + arePanelsCollapsed, + onToggleFullScreen, + onCloseKey, + onEditKey, + onDeleteKey, + keyProp + } = props - const selectedKey = useSelector(selectedKeySelector) + const dispatch = useDispatch() useEffect(() => { if (keyProp === null) { @@ -48,30 +61,40 @@ const KeyDetailsWrapper = ({ onCloseKey, onEditKey, onDeleteKey, keyProp }: Prop dispatch(deleteKeyAction(key, onDeleteKey)) } - const handleRefreshKey = (key: string, type: KeyTypes) => { + const handleRefreshKey = (key: string, type: KeyTypes | ModulesKeyTypes) => { + const resetData = false + dispatch(refreshKeyInfoAction(key)) switch (type) { case KeyTypes.Hash: { - dispatch(refreshKeyInfoAction(key)) - dispatch(refreshHashFieldsAction(key)) + dispatch(refreshHashFieldsAction(key, resetData)) break } case KeyTypes.ZSet: { - dispatch(refreshKeyInfoAction(key)) - dispatch(refreshZsetMembersAction(key)) + dispatch(refreshZsetMembersAction(key, resetData)) break } case KeyTypes.Set: { - dispatch(refreshKeyInfoAction(key)) - dispatch(refreshSetMembersAction(key)) + dispatch(refreshSetMembersAction(key, resetData)) break } case KeyTypes.List: { - dispatch(refreshKeyInfoAction(key)) - dispatch(refreshListElementsAction(key)) + dispatch(refreshListElementsAction(key, resetData)) + break + } + case KeyTypes.String: { + dispatch(fetchString(key, resetData)) + break + } + case KeyTypes.ReJSON: { + dispatch(fetchReJSON(key, '.', resetData)) + break + } + case KeyTypes.Stream: { + dispatch(refreshStreamEntries(key, resetData)) break } default: - dispatch(fetchKeyInfo(key)) + dispatch(fetchKeyInfo(key, resetData)) } } @@ -86,10 +109,18 @@ const KeyDetailsWrapper = ({ onCloseKey, onEditKey, onDeleteKey, keyProp }: Prop onCloseKey() } + const handleClosePanel = () => { + dispatch(toggleBrowserFullScreen(true)) + keyProp && onCloseKey() + } + return ( div { height: 100%; } @@ -42,6 +43,20 @@ } :global(.key-details-body) { - height: calc(100% - 85px); + position: relative; + height: calc(100% - 105px); //min-height: 300px; } + +.closeRightPanel { + position: absolute; + top: 22px; + right: 18px; + + .closeBtn { + :global(svg) { + width: 20px; + height: 20px; + } + } +} diff --git a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx index 31c9ce259b..c84a6d8661 100644 --- a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx @@ -12,7 +12,7 @@ import { formatLongName, replaceSpaces, truncateTTLToDuration, - truncateTTLToFirstUnit, + truncateNumberToFirstUnit, truncateTTLToSeconds, } from 'uiSrc/utils' import { @@ -26,7 +26,7 @@ import { keysSelector, selectedKeySelector, sourceKeysFetch, -} from 'uiSrc/slices/keys' +} from 'uiSrc/slices/browser/keys' import { appContextBrowser, setBrowserKeyListScrollPosition @@ -154,7 +154,7 @@ const KeyList = (props: Props) => { )} > - <>{truncateTTLToFirstUnit(cellData)} + <>{truncateNumberToFirstUnit(cellData)}
@@ -209,7 +209,6 @@ const KeyList = (props: Props) => { headerHeight={0} rowHeight={43} columns={columns} - isRowSelectable loadMoreItems={loadMoreItems} onWheel={onWheelSearched} loading={loading} diff --git a/redisinsight/ui/src/pages/browser/components/key-list/index.ts b/redisinsight/ui/src/pages/browser/components/key-list/index.ts new file mode 100644 index 0000000000..6a6e80dfc1 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-list/index.ts @@ -0,0 +1,3 @@ +import KeyList from './KeyList' + +export default KeyList diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx index 1890b732c4..5711e43233 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx @@ -9,7 +9,7 @@ import { screen, waitFor, } from 'uiSrc/utils/test-utils' -import { setSearchMatch } from 'uiSrc/slices/keys' +import { setSearchMatch } from 'uiSrc/slices/browser/keys' import { KeysStoreData } from 'uiSrc/slices/interfaces/keys' import { mockVirtualTreeResult } from 'uiSrc/components/virtual-tree/VirtualTree.spec' import { setBrowserTreeNodesOpen, setBrowserTreeSelectedLeaf } from 'uiSrc/slices/app/context' diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx index cb8ed3c1ce..855f724a01 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.tsx @@ -9,13 +9,12 @@ import { setBrowserTreeSelectedLeaf } from 'uiSrc/slices/app/context' import { constructKeysToTree } from 'uiSrc/helpers' -import { keysSelector } from 'uiSrc/slices/keys' import VirtualTree from 'uiSrc/components/virtual-tree' import TreeViewSVG from 'uiSrc/assets/img/icons/treeview.svg' import { KeysStoreData } from 'uiSrc/slices/interfaces/keys' import KeyTreeDelimiter from './KeyTreeDelimiter' -import KeyList from '../key-list/KeyList' +import KeyList from '../key-list' import styles from './styles.module.scss' export interface Props { @@ -33,7 +32,6 @@ const KeyTree = (props: Props) => { const firstPanelId = 'tree' const secondPanelId = 'keys' - const { filter, search } = useSelector(keysSelector) const { delimiter, panelSizes, openNodes, selectedLeaf } = useSelector(appContextBrowserTree) const [statusSelected, setStatusSelected] = useState(selectedLeaf) @@ -74,11 +72,10 @@ const KeyTree = (props: Props) => { // select default leaf "Keys" after each change delimiter, filter or search const updateSelectedKeys = () => { - setItems([]) + setItems(keysState.keys) setTimeout(() => { setStatusSelected({}) setSelectDefaultLeaf(true) - setItems(keysState.keys) }, 0) } diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/styles.module.scss index e8f7f0dbb5..3320d27cc7 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTreeDelimiter/styles.module.scss @@ -28,9 +28,9 @@ $selectDelimiterHeight: 18px; height: 84px; width: 182px; padding: 12px 18px !important; - margin-top: -18px; border: 1px solid var(--euiColorPrimary) !important; background-color: var(--euiColorLightestShade) !important; + margin-top: -18px; :global(.euiPopover__panelArrow) { &::before, diff --git a/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx b/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx index fd9c0a80d4..0dadc73965 100644 --- a/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx +++ b/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx @@ -1,12 +1,11 @@ /* eslint-disable react/no-this-in-sfc */ -import React, { Ref, useEffect, useRef, useState, FC, SVGProps } from 'react' +import React, { Ref, useRef, FC, SVGProps } from 'react' import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' -import { formatDistanceToNow } from 'date-fns' +import AutoSizer from 'react-virtualized-auto-sizer' import { EuiButton, EuiButtonIcon, - EuiTextColor, EuiToolTip, } from '@elastic/eui' @@ -15,12 +14,13 @@ import { fetchKeys, keysDataSelector, keysSelector, -} from 'uiSrc/slices/keys' + resetKeysData, +} from 'uiSrc/slices/browser/keys' import { resetBrowserTree, setBrowserKeyListDataLoaded, } from 'uiSrc/slices/app/context' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent } from 'uiSrc/telemetry' import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' import { KeysStoreData, KeyViewType } from 'uiSrc/slices/interfaces/keys' @@ -31,11 +31,12 @@ import { ReactComponent as TreeViewIcon } from 'uiSrc/assets/img/icons/treeview. import FilterKeyType from '../filter-key-type' import SearchKeyList from '../search-key-list' +import AutoRefresh from '../auto-refresh' import styles from './styles.module.scss' -const TIMEOUT_TO_UPDATE_REFRESH_TIME = 1_000 * 60 // once a minute -const HIDE_REFRESH_LABEL_WIDTH = 700 +const HIDE_REFRESH_LABEL_WIDTH = 600 +const FULL_SCREEN_RESOLUTION = 1260 interface IViewType { tooltipText: string @@ -50,22 +51,24 @@ interface IViewType { export interface Props { loading: boolean keysState: KeysStoreData - sizes: any loadKeys: (type?: KeyViewType) => void loadMoreItems?: (config: any) => void handleAddKeyPanel: (value: boolean) => void } const KeysHeader = (props: Props) => { - let interval: NodeJS.Timeout - const { loading, keysState, sizes, loadKeys, loadMoreItems, handleAddKeyPanel } = props + const { + loading, + keysState, + loadKeys, + loadMoreItems, + handleAddKeyPanel, + } = props const { lastRefreshTime } = useSelector(keysDataSelector) const { id: instanceId } = useSelector(connectedInstanceSelector) const { viewType, isSearched, isFiltered } = useSelector(keysSelector) - const [lastRefreshMessage, setLastRefreshMessage] = useState('') - const [showRefreshLabel, setShowRefreshLabel] = useState(true) const rootDivRef: Ref = useRef(null) const dispatch = useDispatch() @@ -104,52 +107,25 @@ const KeysHeader = (props: Props) => { height: '36px !important', } - useEffect(() => { - globalThis.addEventListener('resize', updateSizes) - - return () => { - globalThis.removeEventListener('resize', updateSizes) + const handleRefreshKeys = (enableAutoRefresh: boolean) => { + if (!enableAutoRefresh) { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_LIST_REFRESH_CLICKED, + TelemetryEvent.TREE_VIEW_KEY_LIST_REFRESH_CLICKED + ), + eventData: { + databaseId: instanceId + } + }) } - }, []) - - useEffect(() => { - updateSizes() - }, [sizes]) - - useEffect(() => { - updateLastRefresh() - - interval = setInterval(() => { - if (document.hidden) return - - updateLastRefresh() - }, TIMEOUT_TO_UPDATE_REFRESH_TIME) - return () => clearInterval(interval) - }, [lastRefreshTime]) - - const updateSizes = () => { - const isShowRefreshLabel = (rootDivRef?.current?.offsetWidth || 0) > HIDE_REFRESH_LABEL_WIDTH - setShowRefreshLabel(isShowRefreshLabel) - } - - const handleRefreshKeys = () => { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_LIST_REFRESH_CLICKED, - TelemetryEvent.TREE_VIEW_KEY_LIST_REFRESH_CLICKED - ), - eventData: { - databaseId: instanceId - } - }) dispatch(fetchKeys( '0', viewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT, () => dispatch(setBrowserKeyListDataLoaded(true)), () => dispatch(setBrowserKeyListDataLoaded(false)), )) - dispatch(resetBrowserTree()) } const handleScanMore = (config: any) => { @@ -159,12 +135,6 @@ const KeysHeader = (props: Props) => { }) } - const updateLastRefresh = () => { - lastRefreshTime && setLastRefreshMessage( - `${formatDistanceToNow(lastRefreshTime, { addSuffix: true })}` - ) - } - const openAddKeyPanel = () => { handleAddKeyPanel(true) sendEventTelemetry({ @@ -191,6 +161,7 @@ const KeysHeader = (props: Props) => { dispatch(changeKeyViewType(type)) dispatch(resetBrowserTree()) localStorageService.set(BrowserStorageItem.browserViewType, type) + dispatch(resetKeysData()) loadKeys(type) } @@ -207,8 +178,16 @@ const KeysHeader = (props: Props) => { ) - const ViewSwitch = ( -
+ const ViewSwitch = (width: number) => ( +
HIDE_REFRESH_LABEL_WIDTH, + [styles.fullScreen]: width > FULL_SCREEN_RESOLUTION + }) + } + data-testid="view-type-switcher" + > {viewTypes.map((view) => ( {
) - const RefreshBtn = ( -
- {showRefreshLabel && ( - - Last refresh: - - {` ${lastRefreshMessage}`} - - - )} - - - - -
- ) - return (
-
- - - {ViewSwitch} - {AddKeyBtn} -
- -
- - {RefreshBtn} -
+ + {({ width }) => ( +
+
+ + + {ViewSwitch(width)} +
+ {AddKeyBtn} +
+
+ +
+ + HIDE_REFRESH_LABEL_WIDTH} + containerClassName={styles.refreshContainer} + onRefresh={handleRefreshKeys} + testid="refresh-keys-btn" + /> +
+
+ )} +
) } diff --git a/redisinsight/ui/src/pages/browser/components/keys-header/styles.module.scss b/redisinsight/ui/src/pages/browser/components/keys-header/styles.module.scss index 2e9dd95ae1..45cc27b563 100644 --- a/redisinsight/ui/src/pages/browser/components/keys-header/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/keys-header/styles.module.scss @@ -14,7 +14,7 @@ width: 78px !important; :global(.euiButton__text) { - font: normal normal 500 15px/24px 'Graphik', sans-serif !important; + font: normal normal 500 15px/24px "Graphik", sans-serif !important; } } } @@ -27,39 +27,27 @@ min-height: 38px; } -.bottom { - padding-top: 10px; -} - -.refresh { - right: 18px; - position: absolute; -} - -.btnRefresh { - svg { - width: 20px; - height: 20px; - } +.top { + justify-content: space-between; } -.refreshTime { - color: var(--euiColorWarningLight); - padding-right: 6px; -} - -.refreshSummary { - vertical-align: middle; - font: normal normal normal 12px/18px 'Graphik', sans-serif !important; - letter-spacing: -0.12px; +.bottom { + padding-top: 10px; } -.tooltip { - max-width: 372px !important; +.exitFullScreenBtn { + margin-left: 18px; } .viewTypeSwitch { - padding: 0 24px; + padding: 0 18px; + flex-shrink: 0; + &.middleScreen { + padding: 0 36px; + } + &.fullScreen { + padding: 0 120px; + } } .viewTypeBtn { @@ -87,3 +75,8 @@ } } } + +.refreshContainer { + right: 18px; + position: absolute; +} diff --git a/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.spec.tsx b/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.spec.tsx index dd90a91c3a..2abe1a9d82 100644 --- a/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.spec.tsx @@ -4,8 +4,8 @@ import ListDetails from './ListDetails' const elements = ['element1', 'element2', 'element3'] -jest.mock('uiSrc/slices/list', () => { - const defaultState = jest.requireActual('uiSrc/slices/list').initialState +jest.mock('uiSrc/slices/browser/list', () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/list').initialState return { listSelector: jest.fn().mockReturnValue(defaultState), updateListValueStateSelector: jest diff --git a/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.tsx b/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.tsx index 5b04667fc9..3bc5486d02 100644 --- a/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.tsx +++ b/redisinsight/ui/src/pages/browser/components/list-details/ListDetails.tsx @@ -1,4 +1,4 @@ -import { EuiButtonIcon, EuiText, EuiToolTip } from '@elastic/eui' +import { EuiButtonIcon, EuiProgress, EuiText, EuiToolTip } from '@elastic/eui' import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' @@ -13,8 +13,8 @@ import { updateListElementAction, updateListValueStateSelector, fetchSearchingListElementAction, -} from 'uiSrc/slices/list' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +} from 'uiSrc/slices/browser/list' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent } from 'uiSrc/telemetry' import { KeyTypes } from 'uiSrc/constants' import { @@ -22,7 +22,7 @@ import { IColumnSearchState, } from 'uiSrc/components/virtual-table/interfaces' import { formatLongName, validateListIndex } from 'uiSrc/utils' -import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/keys' +import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/browser/keys' import { NoResultsFoundText } from 'uiSrc/constants/texts' import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' @@ -253,7 +253,16 @@ const ListDetails = (props: Props) => { }, )} > + {loading && ( + + )} { ) @@ -36,7 +35,6 @@ describe('PopoverDelete', () => { item="name" suffix="_" deleting="name_" - keyName="key" handleDeleteItem={handleDeleteItem} /> ) diff --git a/redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx b/redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx index 7e0e57ca20..7b0dee2f43 100644 --- a/redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx +++ b/redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx @@ -1,27 +1,28 @@ import React from 'react' -import { EuiButton, EuiButtonIcon, EuiPopover, EuiSpacer, EuiText } from '@elastic/eui' -import { formatNameShort } from 'uiSrc/utils' +import { EuiButton, EuiButtonIcon, EuiPopover, EuiText } from '@elastic/eui' import styles from './styles.module.scss' export interface Props { - item: string, - suffix: string, - deleting: string, - closePopover: () => void, - showPopover: (item: any) => void, - updateLoading: boolean, - handleDeleteItem: (item: string) => void, - handleButtonClick?: () => void, - keyName: string, + header?: string + text: JSX.Element | string + item: string + suffix: string + deleting: string + closePopover: () => void + showPopover: (item: string) => void + updateLoading: boolean + handleDeleteItem: (item: string) => void + handleButtonClick?: () => void appendInfo?: JSX.Element | string | null - testid?: string; + testid?: string } const PopoverDelete = (props: Props) => { const { + header, + text, item, - keyName, suffix, deleting, closePopover, @@ -33,9 +34,6 @@ const PopoverDelete = (props: Props) => { testid = '', } = props - const shorKeyName = formatNameShort(keyName) - const shorItemName = formatNameShort(item) - const onButtonClick = () => { if (item + suffix !== deleting) { showPopover(item) @@ -47,18 +45,17 @@ const PopoverDelete = (props: Props) => { return ( closePopover()} - panelPaddingSize="l" + panelPaddingSize="m" anchorClassName="deleteFieldPopover" button={( { >
-

- {shorItemName} -

- - will be removed from - {' '} - {shorKeyName} + {!!header && ( +

+ {header} +

+ )} + + {text} {appendInfo}
-
{ color="warning" iconType="trash" onClick={() => handleDeleteItem(item)} - className={styles.popoverDeleteBtn} data-testid={testid || 'remove'} > Remove diff --git a/redisinsight/ui/src/pages/browser/components/popover-delete/styles.module.scss b/redisinsight/ui/src/pages/browser/components/popover-delete/styles.module.scss index 8c32bf95b6..a7ebf14087 100644 --- a/redisinsight/ui/src/pages/browser/components/popover-delete/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/popover-delete/styles.module.scss @@ -1,23 +1,26 @@ -.tooltip { - max-width: 312px !important; +:global { + .euiPanel--paddingMedium { + padding: 18px !important; + } } .popover { - max-width: 312px !important; + max-width: 564px !important; word-wrap: break-word; } -.popoverTitle { - margin-top: 0 !important; -} - .appendInfo { - color: var(--euiTextSubduedColor); - display: flex; margin-top: 1rem; + color: var(--euiTextSubduedColor); > div { font-size: 12px !important; } } + +.popoverFooter { + display: flex; + justify-content: flex-end; + margin-top: 12px; +} diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.spec.tsx b/redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.spec.tsx index 9527be6120..be096e951e 100644 --- a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.spec.tsx @@ -1,7 +1,7 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' -import { fetchVisualisationResults } from 'uiSrc/slices/rejson' +import { fetchVisualisationResults } from 'uiSrc/slices/browser/rejson' import JSONArray, { Props } from './JSONArray' const EXPAND_ARRAY = 'expand-array' @@ -24,8 +24,8 @@ const mockedDownloadedArrayWithArrays = [ [3, 4] ] -jest.mock('uiSrc/slices/rejson', () => ({ - ...jest.requireActual('uiSrc/slices/rejson'), +jest.mock('uiSrc/slices/browser/rejson', () => ({ + ...jest.requireActual('uiSrc/slices/browser/rejson'), appendReJSONArrayItemAction: jest.fn, setReJSONDataAction: jest.fn, fetchVisualisationResults: jest.fn().mockReturnValue( diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.tsx b/redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.tsx index 2988ac8eac..447b696fd5 100644 --- a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.tsx +++ b/redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.tsx @@ -17,7 +17,8 @@ import { appendReJSONArrayItemAction, fetchVisualisationResults, setReJSONDataAction -} from 'uiSrc/slices/rejson' +} from 'uiSrc/slices/browser/rejson' +import { createDeleteFieldHeader, createDeleteFieldMessage } from 'uiSrc/utils' import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' import FieldMessage from 'uiSrc/components/field-message/FieldMessage' @@ -727,8 +728,9 @@ class JSONArrayComponent extends Component { data-testid="btn-edit-field" /> this.setState({ deleting: '' })} diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.spec.tsx b/redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.spec.tsx index 9c2895d6a2..57a5327d09 100644 --- a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.spec.tsx @@ -2,7 +2,7 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { fireEvent, render, screen, mockedStore } from 'uiSrc/utils/test-utils' -import { fetchVisualisationResults, setReJSONDataAction } from 'uiSrc/slices/rejson' +import { fetchVisualisationResults, setReJSONDataAction } from 'uiSrc/slices/browser/rejson' import JSONObject, { Props } from './JSONObject' const EXPAND_OBJECT = 'expand-object' @@ -20,8 +20,8 @@ const mockedDownloadedObjectWithArray = { a: [1, null, 'aaa'] } -jest.mock('uiSrc/slices/rejson', () => ({ - ...jest.requireActual('uiSrc/slices/rejson'), +jest.mock('uiSrc/slices/browser/rejson', () => ({ + ...jest.requireActual('uiSrc/slices/browser/rejson'), setReJSONDataAction: jest.fn, fetchVisualisationResults: jest.fn().mockReturnValue( Promise.resolve({ data: mockedSimpleJSONObject }) diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.tsx b/redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.tsx index 290953aa8c..06e627c665 100644 --- a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.tsx +++ b/redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.tsx @@ -14,7 +14,8 @@ import { import { connect } from 'react-redux' import cx from 'classnames' -import { fetchVisualisationResults, setReJSONDataAction } from 'uiSrc/slices/rejson' +import { fetchVisualisationResults, setReJSONDataAction } from 'uiSrc/slices/browser/rejson' +import { createDeleteFieldHeader, createDeleteFieldMessage } from 'uiSrc/utils' import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' import FieldMessage from 'uiSrc/components/field-message/FieldMessage' @@ -739,8 +740,9 @@ class JSONObject extends React.Component { data-testid="edit-object-btn" /> this.setState({ deleting: '' })} diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONScalar/JSONScalar.tsx b/redisinsight/ui/src/pages/browser/components/rejson-details/JSONScalar/JSONScalar.tsx index 5dde6238db..18a0639a82 100644 --- a/redisinsight/ui/src/pages/browser/components/rejson-details/JSONScalar/JSONScalar.tsx +++ b/redisinsight/ui/src/pages/browser/components/rejson-details/JSONScalar/JSONScalar.tsx @@ -2,9 +2,10 @@ import React, { useEffect, useState } from 'react' import { useDispatch } from 'react-redux' import cx from 'classnames' -import { setReJSONDataAction } from 'uiSrc/slices/rejson' +import { setReJSONDataAction } from 'uiSrc/slices/browser/rejson' import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' +import { createDeleteFieldHeader, createDeleteFieldMessage } from 'uiSrc/utils' import FieldMessage from 'uiSrc/components/field-message/FieldMessage' import { JSONErrors } from '../constants' import { JSONScalarValue, IJSONObject } from '../JSONInterfaces' @@ -57,17 +58,17 @@ const JSONScalar = (props: Props) => { path = keyName.includes('"') ? `${parentPath}['${keyName}']` : `${parentPath}["${keyName}"]` } - let changedValue = value + let val = value if (value === null) { - changedValue = JSON.stringify(value) + val = JSON.stringify(value) } if (typeof value === 'string') { - changedValue = `"${value}"` + val = `"${value}"` } - setChangedValue(changedValue) + setChangedValue(val) setPath(path) - }, [parentPath, keyName]) + }, [parentPath, keyName, value]) const validateJSONValue = (value: JSONScalarValue) => { let error: string = '' @@ -198,8 +199,9 @@ const JSONScalar = (props: Props) => {
setDeleting('')} diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetails/RejsonDetails.tsx b/redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetails/RejsonDetails.tsx index 2f34fcb37c..21a302b954 100644 --- a/redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetails/RejsonDetails.tsx +++ b/redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetails/RejsonDetails.tsx @@ -15,7 +15,7 @@ import { appendReJSONArrayItemAction, removeReJSONKeyAction, setReJSONDataAction -} from 'uiSrc/slices/rejson' +} from 'uiSrc/slices/browser/rejson' import FieldMessage from 'uiSrc/components/field-message/FieldMessage' import { JSONErrors } from '../constants' import JSONScalar from '../JSONScalar/JSONScalar' diff --git a/redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetailsWrapper.tsx b/redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetailsWrapper.tsx index bea4db230f..6ca3a2d8ec 100644 --- a/redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetailsWrapper.tsx +++ b/redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetailsWrapper.tsx @@ -1,11 +1,10 @@ import React from 'react' import { useSelector } from 'react-redux' -import { EuiLoadingSpinner } from '@elastic/eui' +import { EuiProgress } from '@elastic/eui' -import { rejsonDataSelector, rejsonSelector } from 'uiSrc/slices/rejson' -import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/keys' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' -import { KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { rejsonDataSelector, rejsonSelector } from 'uiSrc/slices/browser/rejson' +import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/browser/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent } from 'uiSrc/telemetry' import RejsonDetails from './RejsonDetails/RejsonDetails' @@ -69,11 +68,15 @@ const RejsonDetailsWrapper = () => { return (
- {loading ? ( -
- -
- ) : ( + {loading && ( + + )} + {!(loading && data === undefined) && ( () diff --git a/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx b/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx index 7662122f80..d5e95eadc1 100644 --- a/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx @@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux' import MultiSearch from 'uiSrc/components/multi-search/MultiSearch' import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' import { replaceSpaces } from 'uiSrc/utils' -import { fetchKeys, keysSelector, setFilter, setSearchMatch } from 'uiSrc/slices/keys' +import { fetchKeys, keysSelector, setFilter, setSearchMatch } from 'uiSrc/slices/browser/keys' import { resetBrowserTree } from 'uiSrc/slices/app/context' import { KeyViewType } from 'uiSrc/slices/interfaces/keys' @@ -20,10 +20,10 @@ const SearchKeyList = () => { }, [filter]) const handleApply = () => { - dispatch(fetchKeys('0', viewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT)) - // reset browser tree context dispatch(resetBrowserTree()) + + dispatch(fetchKeys('0', viewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT)) } const handleChangeValue = (initValue: string) => { diff --git a/redisinsight/ui/src/pages/browser/components/search-key-list/styles.module.scss b/redisinsight/ui/src/pages/browser/components/search-key-list/styles.module.scss index 34c8c3f95d..f95651f831 100644 --- a/redisinsight/ui/src/pages/browser/components/search-key-list/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/search-key-list/styles.module.scss @@ -1,5 +1,5 @@ .container { - width: calc(100% - 248px); + flex-grow: 1; height: 36px; margin-left: 48px; position: relative; diff --git a/redisinsight/ui/src/pages/browser/components/set-details/SetDetails.spec.tsx b/redisinsight/ui/src/pages/browser/components/set-details/SetDetails.spec.tsx index ed0ad5911a..9c7917cf6e 100644 --- a/redisinsight/ui/src/pages/browser/components/set-details/SetDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/set-details/SetDetails.spec.tsx @@ -6,8 +6,8 @@ import SetDetails, { Props } from './SetDetails' const members = ['member1', 'member2', 'member3'] const mockedProps = mock() -jest.mock('uiSrc/slices/set', () => { - const defaultState = jest.requireActual('uiSrc/slices/set').initialState +jest.mock('uiSrc/slices/browser/set', () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/set').initialState return ({ setSelector: jest.fn().mockReturnValue(defaultState), setDataSelector: jest.fn().mockReturnValue({ diff --git a/redisinsight/ui/src/pages/browser/components/set-details/SetDetails.tsx b/redisinsight/ui/src/pages/browser/components/set-details/SetDetails.tsx index a931332a47..da68dcc33a 100644 --- a/redisinsight/ui/src/pages/browser/components/set-details/SetDetails.tsx +++ b/redisinsight/ui/src/pages/browser/components/set-details/SetDetails.tsx @@ -2,22 +2,23 @@ import React, { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' import { + EuiProgress, EuiText, EuiToolTip, } from '@elastic/eui' -import { formatLongName } from 'uiSrc/utils' +import { createDeleteFieldHeader, createDeleteFieldMessage, formatLongName } from 'uiSrc/utils' import { KeyTypes } from 'uiSrc/constants' import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent, getMatchType } from 'uiSrc/telemetry' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' -import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/browser/keys' import { deleteSetMembers, fetchSetMembers, fetchMoreSetMembers, setDataSelector, setSelector, -} from 'uiSrc/slices/set' +} from 'uiSrc/slices/browser/set' import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' import HelpTexts from 'uiSrc/constants/help-texts' import { NoResultsFoundText } from 'uiSrc/constants/texts' @@ -59,8 +60,23 @@ const SetDetails = (props: Props) => { setDeleting(`${member + suffix}`) } + const onSuccessRemoved = () => { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Set, + numberOfRemoved: 1, + } + }) + } + const handleDeleteMember = (member = '') => { - dispatch(deleteSetMembers(key, [member])) + dispatch(deleteSetMembers(key, [member], onSuccessRemoved)) closePopover() } @@ -100,7 +116,7 @@ const SetDetails = (props: Props) => { }) } setMatch(match) - dispatch(fetchSetMembers(key, 0, SCAN_COUNT_DEFAULT, match || matchAllValue, onSuccess)) + dispatch(fetchSetMembers(key, 0, SCAN_COUNT_DEFAULT, match || matchAllValue, true, onSuccess)) } const columns:ITableColumn[] = [ @@ -145,8 +161,9 @@ const SetDetails = (props: Props) => { return (
{ ) } > + {loading && ( + + )} () + +describe('StreamDetails', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/StreamDetails.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/StreamDetails.tsx new file mode 100644 index 0000000000..5171aa482b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/StreamDetails.tsx @@ -0,0 +1,274 @@ +import React, { useCallback, useMemo, useState, useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { last, isNull } from 'lodash' +import cx from 'classnames' +import { EuiButtonIcon, EuiProgress } from '@elastic/eui' + +import { + fetchMoreStreamEntries, + fetchStreamEntries, + updateStart, + updateEnd, + streamDataSelector, + streamSelector, + streamRangeSelector, +} from 'uiSrc/slices/browser/stream' +import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' +import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' +import RangeFilter from 'uiSrc/components/range-filter/RangeFilter' +import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' +import { SortOrder } from 'uiSrc/constants' +import { getTimestampFromId } from 'uiSrc/utils/streamUtils' +import { StreamEntryDto, GetStreamEntriesResponse } from 'apiSrc/modules/browser/dto/stream.dto' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' + +import styles from './styles.module.scss' + +const headerHeight = 60 +const rowHeight = 54 +const actionsWidth = 54 +const minColumnWidth = 190 +const noItemsMessageInEmptyStream = 'There are no Entries in the Stream.' +const noItemsMessageInRange = 'No results found.' + +interface IStreamEntry extends StreamEntryDto { + editing: boolean +} + +const getNextId = (id: string, sortOrder: SortOrder): string => { + const splittedId = id.split('-') + // if we don't have prefix + if (splittedId.length === 1) { + return `${id}-1` + } + if (sortOrder === SortOrder.DESC) { + return splittedId[1] === '0' ? `${parseInt(splittedId[0], 10) - 1}` : `${splittedId[0]}-${+splittedId[1] - 1}` + } + return `${splittedId[0]}-${+splittedId[1] + 1}` +} + +export interface Props { + data: IStreamEntry[] + columns: ITableColumn[] + onEditEntry: (entryId:string, editing: boolean) => void + onClosePopover: () => void + isFooterOpen?: boolean +} + +const StreamDetails = (props: Props) => { + const { data: entries = [], columns = [], onClosePopover, isFooterOpen } = props + const dispatch = useDispatch() + + const { loading } = useSelector(streamSelector) + const { start, end } = useSelector(streamRangeSelector) + const { + total, + firstEntry, + lastEntry, + } = useSelector(streamDataSelector) + const { name: key } = useSelector(selectedKeyDataSelector) ?? { name: '' } + const { id: instanceId } = useSelector(connectedInstanceSelector) + + const shouldFilterRender = !isNull(firstEntry) && (firstEntry.id !== '') && !isNull(lastEntry) && lastEntry.id !== '' + + const [sortedColumnName, setSortedColumnName] = useState('id') + const [sortedColumnOrder, setSortedColumnOrder] = useState(SortOrder.DESC) + + const loadMoreItems = () => { + const lastLoadedEntryId = last(entries)?.id + const lastLoadedEntryTimeStamp = getTimestampFromId(lastLoadedEntryId) + + const lastRangeEntryTimestamp = end ? parseInt(end, 10) : getTimestampFromId(lastEntry?.id) + const firstRangeEntryTimestamp = start ? parseInt(start, 10) : getTimestampFromId(firstEntry?.id) + const shouldLoadMore = () => { + if (!lastLoadedEntryTimeStamp) { + return false + } + return sortedColumnOrder === SortOrder.ASC + ? lastLoadedEntryTimeStamp <= lastRangeEntryTimestamp + : lastLoadedEntryTimeStamp >= firstRangeEntryTimestamp + } + const nextId = getNextId(lastLoadedEntryId, sortedColumnOrder) + + if (shouldLoadMore()) { + dispatch( + fetchMoreStreamEntries( + key, + sortedColumnOrder === SortOrder.DESC ? start : nextId, + sortedColumnOrder === SortOrder.DESC ? nextId : end, + SCAN_COUNT_DEFAULT, + sortedColumnOrder, + ) + ) + } + } + + const filterTelementry = (data: GetStreamEntriesResponse) => { + sendEventTelemetry({ + event: TelemetryEvent.STREAM_DATA_FILTERED, + eventData: { + databaseId: instanceId, + total: data.total, + } + }) + } + + const resetFilterTelementry = (data: GetStreamEntriesResponse) => { + sendEventTelemetry({ + event: TelemetryEvent.STREAM_DATA_FILTER_RESET, + eventData: { + databaseId: instanceId, + total: data.total, + } + }) + } + + const loadEntries = (telemetryAction?: (data: GetStreamEntriesResponse) => void) => { + dispatch(fetchStreamEntries( + key, + SCAN_COUNT_DEFAULT, + sortedColumnOrder, + false, + telemetryAction + )) + } + + const onChangeSorting = (column: any, order: SortOrder) => { + setSortedColumnName(column) + setSortedColumnOrder(order) + + dispatch(fetchStreamEntries(key, SCAN_COUNT_DEFAULT, order, false)) + } + + const handleChangeStartFilter = useCallback( + (value: number, shouldSentEventTelemetry: boolean) => { + dispatch(updateStart(value.toString())) + loadEntries(shouldSentEventTelemetry ? filterTelementry : undefined) + }, + [] + ) + + const handleChangeEndFilter = useCallback( + (value: number, shouldSentEventTelemetry: boolean) => { + dispatch(updateEnd(value.toString())) + loadEntries(shouldSentEventTelemetry ? filterTelementry : undefined) + }, + [] + ) + + const firstEntryTimeStamp = useMemo(() => getTimestampFromId(firstEntry?.id), [firstEntry?.id]) + const lastEntryTimeStamp = useMemo(() => getTimestampFromId(lastEntry?.id), [lastEntry?.id]) + + const startNumber = useMemo(() => (start === '' ? 0 : parseInt(start, 10)), [start]) + const endNumber = useMemo(() => (end === '' ? 0 : parseInt(end, 10)), [end]) + + const handleResetFilter = useCallback( + () => { + dispatch(updateStart(firstEntryTimeStamp.toString())) + dispatch(updateEnd(lastEntryTimeStamp.toString())) + loadEntries(resetFilterTelementry) + }, + [lastEntryTimeStamp, firstEntryTimeStamp] + ) + + const handleUpdateRangeMin = useCallback( + (min: number) => { + dispatch(updateStart(min.toString())) + }, + [] + ) + + const handleUpdateRangeMax = useCallback( + (max: number) => { + dispatch(updateEnd(max.toString())) + }, + [] + ) + + useEffect(() => { + if (isNull(firstEntry)) { + dispatch(updateStart('')) + } + if (start === '' && firstEntry?.id !== '') { + dispatch(updateStart(firstEntryTimeStamp.toString())) + } + }, [firstEntryTimeStamp]) + + useEffect(() => { + if (isNull(lastEntry)) { + dispatch(updateEnd('')) + } + if (end === '' && lastEntry?.id !== '') { + dispatch(updateEnd(lastEntryTimeStamp.toString())) + } + }, [lastEntryTimeStamp]) + + return ( + <> + {loading && ( + + )} + {shouldFilterRender ? ( + + ) + : ( +
+
+
+ )} +
+ {/*
+ +
*/} + +
+ + ) +} + +export default StreamDetails diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/index.ts b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/index.ts new file mode 100644 index 0000000000..7e4044b399 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/index.ts @@ -0,0 +1,3 @@ +import StreamDetails from './StreamDetails' + +export default StreamDetails diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/styles.module.scss b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/styles.module.scss new file mode 100644 index 0000000000..d285e15d18 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/styles.module.scss @@ -0,0 +1,113 @@ +.container { + display: flex; + flex: 1; + width: 100%; + padding: 16px 18px; + background-color: var(--euiColorEmptyShade); + + :global { + .ReactVirtualized__Grid__innerScrollContainer { + .ReactVirtualized__Table__rowColumn { + border-right: 1px solid var(--tableDarkestBorderColor) !important; + + &:last-of-type, + &:nth-last-of-type(2) { + border-right: none !important; + } + } + + .ReactVirtualized__Table__row { + border-bottom: 1px solid var(--tableDarkestBorderColor) !important; + &:last-of-type { + border-bottom: none !important; + } + } + + & > div:hover { + background: var(--euiColorLightestShade); + + .value-table-actions { + background-color: var(--euiColorLightestShade) !important; + } + + .streamEntry { + color: var(--inputTextColor) !important; + } + + .streamEntryId { + color: var(--euiTextSubduedColor) !important; + } + } + } + + .ReactVirtualized__Table__headerRow { + border: none !important; + } + + .ReactVirtualized__Table__Grid { + border: 1px solid var(--tableDarkestBorderColor) !important; + } + } + + .cellHeader { + border: none !important; + } +} + +:global(.streamEntry) { + color: var(--euiTextSubduedColor) !important; + white-space: normal; + max-width: 100%; + word-break: break-all; +} + +:global(.streamEntryId) { + color: var(--euiColorMediumShade) !important; + display: flex; +} + +:global(.stream-entry-actions) { + margin-left: -5px; +} + +.actions, +.actionsHeader { + width: 54px; +} + +.actions { + :global(.value-table-actions) { + background-color: var(--euiColorEmptyShade) !important; + } +} + +.columnManager { + z-index: 11; + position: absolute; + right: 18px; + margin-top: 20px; + width: 40px; + button { + width: 40px; + background-color: var(--euiColorEmptyShade) !important; + } +} + +.rangeWrapper { + margin: 30px 30px 26px; + padding: 12px 0; +} + +.sliderTrack.mockRange { + left: 18px; + width: calc(100% - 36px); +} + +.sliderTrack { + position: absolute; + background-color: var(--separatorColor); + width: 100%; + height: 1px; + margin-top: 2px; + z-index: 1; +} diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.spec.tsx new file mode 100644 index 0000000000..93a4c23b59 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import StreamDetailsWrapper, { Props } from './StreamDetailsWrapper' + +const mockedProps = mock() + +describe('StreamDetailsWrapper', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.tsx new file mode 100644 index 0000000000..274abf5e30 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.tsx @@ -0,0 +1,230 @@ +import { EuiText, EuiToolTip } from '@elastic/eui' +import React, { useCallback, useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { keyBy } from 'lodash' + +import { formatLongName } from 'uiSrc/utils' +import { streamDataSelector, deleteStreamEntry } from 'uiSrc/slices/browser/stream' +import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' +import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' +import { getFormatTime } from 'uiSrc/utils/streamUtils' +import { KeyTypes, TableCellTextAlignment } from 'uiSrc/constants' +import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { keysSelector } from 'uiSrc/slices/browser/keys' +import { StreamEntryDto } from 'apiSrc/modules/browser/dto/stream.dto' + +import StreamDetails from './StreamDetails' + +import styles from './StreamDetails/styles.module.scss' + +export interface IStreamEntry extends StreamEntryDto { + editing: boolean +} + +const suffix = '_stream' +const actionsWidth = 50 +const minColumnWidth = 190 + +interface Props { + isFooterOpen: boolean +} + +const StreamDetailsWrapper = (props: Props) => { + const { + entries: loadedEntries = [], + keyName: key + } = useSelector(streamDataSelector) + const { id: instanceId } = useSelector(connectedInstanceSelector) + const { viewType } = useSelector(keysSelector) + + const dispatch = useDispatch() + + const [uniqFields, setUniqFields] = useState({}) + const [entries, setEntries] = useState([]) + const [columns, setColumns] = useState([]) + const [deleting, setDeleting] = useState('') + + useEffect(() => { + let fields = {} + const streamEntries: IStreamEntry[] = loadedEntries?.map((item) => { + fields = { + ...fields, + ...keyBy(Object.keys(item.fields)) + } + + return { + ...item, + editing: false, + } + }) + + setUniqFields(fields) + setEntries(streamEntries) + setColumns([idColumn, ...Object.keys(fields).map((field) => getTemplateColumn(field)), actionsColumn]) + }, [loadedEntries, deleting]) + + const closePopover = useCallback(() => { + setDeleting('') + }, []) + + const showPopover = useCallback((entry = '') => { + setDeleting(`${entry + suffix}`) + }, []) + + const onSuccessRemoved = () => { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Stream, + numberOfRemoved: 1, + } + }) + } + + const handleDeleteEntry = (entryId = '') => { + dispatch(deleteStreamEntry(key, [entryId], onSuccessRemoved)) + closePopover() + } + + const handleRemoveIconClick = () => { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVE_CLICKED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVE_CLICKED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Stream + } + }) + } + + const handleEditEntry = (entryId = '', editing: boolean) => { + const newFieldsState = entries.map((item) => { + if (item.id === entryId) { + return { ...item, editing } + } + return item + }) + setEntries(newFieldsState) + } + + const getTemplateColumn = (label: string) : ITableColumn => ({ + id: label, + label, + minWidth: minColumnWidth, + isSortable: false, + className: styles.cell, + headerClassName: styles.cellHeader, + headerCellClassName: 'truncateText', + render: function Id(_name: string, { id, fields }: StreamEntryDto) { + const value = fields[label] ?? '' + const cellContent = value.substring(0, 200) + const tooltipContent = formatLongName(value) + + return ( + +
+ + <>{cellContent} + +
+
+ ) + } + }) + + const [idColumn, actionsColumn]: ITableColumn[] = [ + { + id: 'id', + label: 'Entry ID', + absoluteWidth: minColumnWidth, + minWidth: minColumnWidth, + isSortable: true, + className: styles.cell, + headerClassName: styles.cellHeader, + render: function Id(_name: string, { id }: StreamEntryDto) { + const timestamp = id.split('-')?.[0] + return ( +
+ +
+ {getFormatTime(timestamp)} +
+
+ +
+ {id} +
+
+
+ ) + }, + }, + { + id: 'actions', + label: '', + headerClassName: styles.actionsHeader, + textAlignment: TableCellTextAlignment.Left, + absoluteWidth: actionsWidth, + maxWidth: actionsWidth, + minWidth: actionsWidth, + render: function Actions(_act: any, { id }: StreamEntryDto) { + return ( +
+ + will be removed from + {' '} + {key} + + )} + item={id} + suffix={suffix} + deleting={deleting} + closePopover={closePopover} + updateLoading={false} + showPopover={showPopover} + testid={`remove-entry-button-${id}`} + handleDeleteItem={handleDeleteEntry} + handleButtonClick={handleRemoveIconClick} + /> +
+ ) + }, + }, + ] + + return ( + <> + + + ) +} + +export default StreamDetailsWrapper diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/index.ts b/redisinsight/ui/src/pages/browser/components/stream-details/index.ts new file mode 100644 index 0000000000..b1726a2381 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/index.ts @@ -0,0 +1,3 @@ +import StreamDetailsWrapper from './StreamDetailsWrapper' + +export default StreamDetailsWrapper diff --git a/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.tsx b/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.tsx index c597e9872f..036d9be880 100644 --- a/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.tsx +++ b/redisinsight/ui/src/pages/browser/components/string-details/StringDetails.tsx @@ -7,7 +7,7 @@ import React, { useState, } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { EuiLoadingSpinner, EuiText, EuiTextArea } from '@elastic/eui' +import { EuiProgress, EuiText, EuiTextArea } from '@elastic/eui' import { Nullable } from 'uiSrc/utils' import { @@ -15,7 +15,7 @@ import { stringDataSelector, stringSelector, updateStringValueAction, -} from 'uiSrc/slices/string' +} from 'uiSrc/slices/browser/string' import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' import { AddStringFormConfig as config } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' @@ -99,11 +99,14 @@ const StringDetails = (props: Props) => { return (
{isLoading && ( -
- -
+ )} - {!isEditItem && !isLoading && ( + {!isEditItem && ( setIsEdit(true)} > diff --git a/redisinsight/ui/src/pages/browser/components/string-details/styles.module.scss b/redisinsight/ui/src/pages/browser/components/string-details/styles.module.scss index aa0320ad54..0629ce9c5a 100644 --- a/redisinsight/ui/src/pages/browser/components/string-details/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/string-details/styles.module.scss @@ -16,6 +16,7 @@ $outer-height-mobile: 340px; color: var(--euiTextSubduedColor); flex: 1; + position: relative; @media only screen and (max-width: 767px) { max-height: calc(100vh - #{$outer-height-mobile}); @@ -34,14 +35,6 @@ $outer-height-mobile: 340px; background: inherit !important; } -.spinnerWrapper { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -} - .stringTextArea { max-height: calc(100vh - #{$outer-height} - 55px); @media only screen and (max-width: 767px) { diff --git a/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.spec.tsx b/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.spec.tsx index 8cb07a1b97..25703500eb 100644 --- a/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.spec.tsx @@ -5,8 +5,8 @@ import ZSetDetails, { Props } from './ZSetDetails' const mockedProps = mock() -jest.mock('uiSrc/slices/zset', () => { - const defaultState = jest.requireActual('uiSrc/slices/zset').initialState +jest.mock('uiSrc/slices/browser/zset', () => { + const defaultState = jest.requireActual('uiSrc/slices/browser/zset').initialState return ({ zsetSelector: jest.fn().mockReturnValue(defaultState), setZsetInitialState: jest.fn, diff --git a/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.tsx b/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.tsx index 7c67998ae4..ac8c44e443 100644 --- a/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.tsx +++ b/redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { toNumber } from 'lodash' import cx from 'classnames' -import { EuiButtonIcon, EuiText, EuiToolTip } from '@elastic/eui' +import { EuiButtonIcon, EuiProgress, EuiText, EuiToolTip } from '@elastic/eui' import { zsetSelector, @@ -14,15 +14,15 @@ import { updateZSetMembers, fetchSearchZSetMembers, fetchSearchMoreZSetMembers, -} from 'uiSrc/slices/zset' +} from 'uiSrc/slices/browser/zset' import { KeyTypes, SortOrder } from 'uiSrc/constants' import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' import HelpTexts from 'uiSrc/constants/help-texts' import { NoResultsFoundText } from 'uiSrc/constants/texts' -import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/keys' -import { formatLongName, validateScoreNumber } from 'uiSrc/utils' +import { selectedKeyDataSelector, keysSelector } from 'uiSrc/slices/browser/keys' +import { createDeleteFieldHeader, createDeleteFieldMessage, formatLongName, validateScoreNumber } from 'uiSrc/utils' import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent, getMatchType } from 'uiSrc/telemetry' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' import { IColumnSearchState, ITableColumn } from 'uiSrc/components/virtual-table/interfaces' @@ -74,8 +74,23 @@ const ZSetDetails = (props: Props) => { setDeleting(`${member + suffix}`) }, []) + const onSuccessRemoved = () => { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.ZSet, + numberOfRemoved: 1, + } + }) + } + const handleDeleteMember = (member = '') => { - dispatch(deleteZSetMembers(key, [member])) + dispatch(deleteZSetMembers(key, [member], onSuccessRemoved)) closePopover() } @@ -249,8 +264,9 @@ const ZSetDetails = (props: Props) => { data-testid={`zset-edit-button-${name}`} /> { { footerOpened: isFooterOpen } )} > + {loading && ( + + )} ({ +jest.mock('uiSrc/slices/instances/instances', () => ({ checkConnectToInstanceAction: () => jest.fn, resetInstanceUpdateAction: () => jest.fn, changeInstanceAliasAction: () => jest.fn, diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx index eb334b0510..c58b63a7c7 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx @@ -33,11 +33,10 @@ import { FormikErrors, useFormik } from 'formik' import cx from 'classnames' import { MAX_PORT_NUMBER, - MAX_DATABASE_INDEX_NUMBER, + validateNumber, validateCertName, validateField, validatePortNumber, - validateDatabaseNumber, } from 'uiSrc/utils/validations' import { ConnectionType, @@ -49,13 +48,13 @@ import { checkConnectToInstanceAction, resetInstanceUpdateAction, setConnectedInstanceId, -} from 'uiSrc/slices/instances' +} from 'uiSrc/slices/instances/instances' import { handlePasteHostName } from 'uiSrc/utils' import { APPLICATION_NAME, PageNames, Pages } from 'uiSrc/constants' import { useResizableFormField } from 'uiSrc/services' import validationErrors from 'uiSrc/constants/validationErrors' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { resetKeys } from 'uiSrc/slices/keys' +import { resetKeys } from 'uiSrc/slices/browser/keys' import { appContextSelector, setAppContextInitialState } from 'uiSrc/slices/app/context' import DatabaseAlias from 'uiSrc/pages/home/components/DatabaseAlias' import { DatabaseListModules } from 'uiSrc/components' @@ -753,7 +752,7 @@ const AddStandaloneForm = (props: Props) => { { [styles.dbInputBig]: !flexItemClassName } )} > - + { onChange={(e: ChangeEvent) => { formik.setFieldValue( e.target.name, - validateDatabaseNumber(e.target.value.trim()) + validateNumber(e.target.value.trim()) ) }} type="text" min={0} - max={MAX_DATABASE_INDEX_NUMBER} /> diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.spec.tsx index fc7b4f395d..8075d25ccc 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.spec.tsx @@ -36,23 +36,23 @@ jest.mock('./InstanceForm/InstanceForm', () => ({ default: jest.fn(), })) -jest.mock('uiSrc/slices/instances', () => ({ +jest.mock('uiSrc/slices/instances/instances', () => ({ createInstanceStandaloneAction: () => jest.fn, updateInstanceAction: () => jest.fn, instancesSelector: jest.fn().mockReturnValue({ loadingChanging: false }), })) -jest.mock('uiSrc/slices/clientCerts', () => ({ +jest.mock('uiSrc/slices/instances/clientCerts', () => ({ clientCertsSelector: () => jest.fn().mockReturnValue({ data: [] }), fetchClientCerts: jest.fn, })) -jest.mock('uiSrc/slices/caCerts', () => ({ +jest.mock('uiSrc/slices/instances/caCerts', () => ({ caCertsSelector: () => jest.fn().mockReturnValue({ data: [] }), fetchCaCerts: () => jest.fn, })) -jest.mock('uiSrc/slices/sentinel', () => ({ +jest.mock('uiSrc/slices/instances/sentinel', () => ({ sentinelSelector: () => jest.fn().mockReturnValue({ loading: false }), fetchMastersSentinelAction: () => jest.fn, })) diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx index 6ea18f182f..acecd0906b 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx @@ -9,11 +9,11 @@ import { createInstanceStandaloneAction, updateInstanceAction, instancesSelector, -} from 'uiSrc/slices/instances' +} from 'uiSrc/slices/instances/instances' import { clientCertsSelector, fetchClientCerts, -} from 'uiSrc/slices/clientCerts' +} from 'uiSrc/slices/instances/clientCerts' import { Nullable, removeEmpty } from 'uiSrc/utils' import { ConnectionType, @@ -21,12 +21,12 @@ import { InstanceType, } from 'uiSrc/slices/interfaces' import { localStorageService } from 'uiSrc/services' -import { caCertsSelector, fetchCaCerts } from 'uiSrc/slices/caCerts' +import { caCertsSelector, fetchCaCerts } from 'uiSrc/slices/instances/caCerts' import { DbType, BrowserStorageItem, REDIS_URI_SCHEMES, Pages } from 'uiSrc/constants' import { fetchMastersSentinelAction, sentinelSelector, -} from 'uiSrc/slices/sentinel' +} from 'uiSrc/slices/instances/sentinel' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import InstanceForm, { diff --git a/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.tsx b/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.tsx index 0f3f887fd6..c6d5e5a1c4 100644 --- a/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.tsx @@ -3,7 +3,7 @@ import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import { Pages } from 'uiSrc/constants' -import { cloudSelector, fetchSubscriptionsRedisCloud } from 'uiSrc/slices/cloud' +import { cloudSelector, fetchSubscriptionsRedisCloud } from 'uiSrc/slices/instances/cloud' import { useResizableFormField } from 'uiSrc/services' import { resetErrors } from 'uiSrc/slices/app/notifications' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' diff --git a/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.tsx b/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.tsx index a8b3a7c1fb..2f2d590be5 100644 --- a/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.tsx @@ -6,7 +6,7 @@ import { useHistory } from 'react-router-dom' import { clusterSelector, fetchInstancesRedisCluster, -} from 'uiSrc/slices/cluster' +} from 'uiSrc/slices/instances/cluster' import { REDIS_URI_SCHEMES, Pages } from 'uiSrc/constants' import { useResizableFormField } from 'uiSrc/services' import { resetErrors } from 'uiSrc/slices/app/notifications' diff --git a/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx b/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx index 219613c3c2..91415f679c 100644 --- a/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx @@ -80,8 +80,12 @@ const DatabaseAlias = (props: Props) => { return ( - - + + {isRediStack && ( void; onDeleteInstances: (instances: Instance[]) => void; } + +const suffix = '_db_instance' + const DatabasesListWrapper = ({ width, dialogIsOpen, @@ -65,23 +67,15 @@ const DatabasesListWrapper = ({ const { contextInstanceId, lastPage } = useSelector(appContextSelector) const instances = useSelector(instancesSelector) const [, forceRerender] = useState({}) - const [deleting, setDeleting] = useState({ id: '' }) + const deleting = { id: '' } const closePopover = () => { deleting.id = '' - - setDeleting(deleting) forceRerender({}) } - const showPopover = (instanceId = '') => { - if (deleting.id === instanceId) { - closePopover() - return - } - deleting.id = instanceId - - setDeleting(deleting) + const showPopover = (id: string) => { + deleting.id = `${id + suffix}` forceRerender({}) } @@ -138,14 +132,14 @@ const DatabasesListWrapper = ({ dispatch(checkConnectToInstanceAction(id, connectToInstance)) } - const handleClickDeleteInstance = (instance: Instance) => { + const handleClickDeleteInstance = (id: string) => { sendEventTelemetry({ event: TelemetryEvent.CONFIG_DATABASES_SINGLE_DATABASE_DELETE_CLICKED, eventData: { - databaseId: instance.id + databaseId: id } }) - showPopover(instance.id) + showPopover(id) } const handleClickEditInstance = (instance: Instance) => { @@ -166,48 +160,6 @@ const DatabasesListWrapper = ({ dispatch(deleteInstancesAction(instances, () => onDeleteInstances(instances))) } - const PopoverDelete = ({ id, ...instance }: Instance) => ( - closePopover()} - panelPaddingSize="l" - anchorClassName="deleteInstancePopover" - button={( - handleClickDeleteInstance({ id, ...instance })} - /> - )} - > - -

- - {formatLongName(instance.name, 50, 10, '...')} - -  will be deleted from RedisInsight. -

-
-
- handleDeleteInstance({ id, ...instance })} - > - Delete - -
-
- ) - const columnsFull: EuiTableFieldDataColumnType[] = [ { field: 'name', @@ -344,7 +296,7 @@ const DatabasesListWrapper = ({ className: 'column_controls', width: '100px', name: '', - render: function Icons(_: string, instance: Instance) { + render: function Actions(_act: any, instance: Instance) { return ( <> handleClickEditInstance(instance)} /> - {PopoverDelete(instance)} + handleDeleteInstance(instance)} + handleButtonClick={() => handleClickDeleteInstance(instance.id)} + testid={`delete-instance-${instance.id}`} + /> ) }, diff --git a/redisinsight/ui/src/pages/instance/InstancePage.tsx b/redisinsight/ui/src/pages/instance/InstancePage.tsx index 384a81c9b4..5ed0d93279 100644 --- a/redisinsight/ui/src/pages/instance/InstancePage.tsx +++ b/redisinsight/ui/src/pages/instance/InstancePage.tsx @@ -7,13 +7,13 @@ import cx from 'classnames' import { fetchInstanceAction, fetchInstancesAction, getDatabaseConfigInfoAction, instancesSelector, -} from 'uiSrc/slices/instances' +} from 'uiSrc/slices/instances/instances' import { appContextSelector, setAppContextConnectedInstanceId, setAppContextInitialState, } from 'uiSrc/slices/app/context' -import { resetKeysData } from 'uiSrc/slices/keys' +import { resetKeysData } from 'uiSrc/slices/browser/keys' import { BrowserStorageItem } from 'uiSrc/constants' import { localStorageService } from 'uiSrc/services' import { resetOutput } from 'uiSrc/slices/cli/cli-output' diff --git a/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabases/RedisCloudDatabases.tsx b/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabases/RedisCloudDatabases.tsx index 181e4a5c95..6a619536f5 100644 --- a/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabases/RedisCloudDatabases.tsx +++ b/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabases/RedisCloudDatabases.tsx @@ -20,7 +20,7 @@ import { useHistory } from 'react-router-dom' import cx from 'classnames' import { Pages } from 'uiSrc/constants' -import { cloudSelector } from 'uiSrc/slices/cloud' +import { cloudSelector } from 'uiSrc/slices/instances/cloud' import { InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { PageHeader } from 'uiSrc/components' import validationErrors from 'uiSrc/constants/validationErrors' diff --git a/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabasesPage.tsx b/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabasesPage.tsx index f6318dbcd5..792c2c9220 100644 --- a/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabasesPage.tsx +++ b/redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabasesPage.tsx @@ -14,7 +14,7 @@ import { cloudSelector, resetDataRedisCloud, resetLoadedRedisCloud, -} from 'uiSrc/slices/cloud' +} from 'uiSrc/slices/instances/cloud' import { formatLongName, parseInstanceOptionsCloud, diff --git a/redisinsight/ui/src/pages/redisCloudDatabasesResult/RedisCloudDatabasesResult.tsx b/redisinsight/ui/src/pages/redisCloudDatabasesResult/RedisCloudDatabasesResult.tsx index eb2edcd711..2831a6df0b 100644 --- a/redisinsight/ui/src/pages/redisCloudDatabasesResult/RedisCloudDatabasesResult.tsx +++ b/redisinsight/ui/src/pages/redisCloudDatabasesResult/RedisCloudDatabasesResult.tsx @@ -18,7 +18,7 @@ import { AddRedisDatabaseStatus, } from 'uiSrc/slices/interfaces' import { PageHeader } from 'uiSrc/components' -import { cloudSelector } from 'uiSrc/slices/cloud' +import { cloudSelector } from 'uiSrc/slices/instances/cloud' import MessageBar from 'uiSrc/components/message-bar/MessageBar' import styles from './styles.module.scss' diff --git a/redisinsight/ui/src/pages/redisCloudDatabasesResult/RedisCloudDatabasesResultPage.tsx b/redisinsight/ui/src/pages/redisCloudDatabasesResult/RedisCloudDatabasesResultPage.tsx index 47b25f0aea..83952be7ea 100644 --- a/redisinsight/ui/src/pages/redisCloudDatabasesResult/RedisCloudDatabasesResultPage.tsx +++ b/redisinsight/ui/src/pages/redisCloudDatabasesResult/RedisCloudDatabasesResultPage.tsx @@ -16,7 +16,7 @@ import { cloudSelector, resetDataRedisCloud, resetLoadedRedisCloud, -} from 'uiSrc/slices/cloud' +} from 'uiSrc/slices/instances/cloud' import { InstanceRedisCloud, AddRedisDatabaseStatus, diff --git a/redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptionsPage.tsx b/redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptionsPage.tsx index 8b1b325edc..31891ad933 100644 --- a/redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptionsPage.tsx +++ b/redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptionsPage.tsx @@ -21,7 +21,7 @@ import { fetchInstancesRedisCloud, resetDataRedisCloud, resetLoadedRedisCloud, -} from 'uiSrc/slices/cloud' +} from 'uiSrc/slices/instances/cloud' import { formatLongName, Maybe, replaceSpaces, setTitle } from 'uiSrc/utils' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import RedisCloudSubscriptions from './RedisCloudSubscriptions/RedisCloudSubscriptions' diff --git a/redisinsight/ui/src/pages/redisCluster/RedisClusterDatabases.tsx b/redisinsight/ui/src/pages/redisCluster/RedisClusterDatabases.tsx index 31431a85c2..ae9cf41cb4 100644 --- a/redisinsight/ui/src/pages/redisCluster/RedisClusterDatabases.tsx +++ b/redisinsight/ui/src/pages/redisCluster/RedisClusterDatabases.tsx @@ -20,7 +20,7 @@ import { useSelector } from 'react-redux' import { Maybe } from 'uiSrc/utils' import { PageHeader } from 'uiSrc/components' import { InstanceRedisCluster } from 'uiSrc/slices/interfaces' -import { clusterSelector } from 'uiSrc/slices/cluster' +import { clusterSelector } from 'uiSrc/slices/instances/cluster' import validationErrors from 'uiSrc/constants/validationErrors' import styles from './styles.module.scss' diff --git a/redisinsight/ui/src/pages/redisCluster/RedisClusterDatabasesPage.tsx b/redisinsight/ui/src/pages/redisCluster/RedisClusterDatabasesPage.tsx index 08ef27af6d..eec84eef2c 100644 --- a/redisinsight/ui/src/pages/redisCluster/RedisClusterDatabasesPage.tsx +++ b/redisinsight/ui/src/pages/redisCluster/RedisClusterDatabasesPage.tsx @@ -18,7 +18,7 @@ import { clusterSelector, resetDataRedisCluster, resetInstancesRedisCluster, -} from 'uiSrc/slices/cluster' +} from 'uiSrc/slices/instances/cluster' import { Maybe, formatLongName, parseInstanceOptionsCluster, setTitle } from 'uiSrc/utils' import { InstanceRedisCluster, AddRedisDatabaseStatus } from 'uiSrc/slices/interfaces' import { DatabaseListModules, DatabaseListOptions } from 'uiSrc/components' diff --git a/redisinsight/ui/src/pages/redisCluster/RedisClusterDatabasesResult.tsx b/redisinsight/ui/src/pages/redisCluster/RedisClusterDatabasesResult.tsx index 2d3c4262c6..6dcf2df0b3 100644 --- a/redisinsight/ui/src/pages/redisCluster/RedisClusterDatabasesResult.tsx +++ b/redisinsight/ui/src/pages/redisCluster/RedisClusterDatabasesResult.tsx @@ -20,7 +20,7 @@ import { } from 'uiSrc/slices/interfaces' import { setTitle } from 'uiSrc/utils' import { PageHeader } from 'uiSrc/components' -import { clusterSelector } from 'uiSrc/slices/cluster' +import { clusterSelector } from 'uiSrc/slices/instances/cluster' import MessageBar from 'uiSrc/components/message-bar/MessageBar' import styles from './styles.module.scss' diff --git a/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx b/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx index 2680c64d41..24e2017dae 100644 --- a/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx +++ b/redisinsight/ui/src/pages/redisStack/components/edit-connection/EditConnection.tsx @@ -8,7 +8,7 @@ import { apiService } from 'uiSrc/services' import { ApiEndpoints, Pages } from 'uiSrc/constants' import { PageHeader, PagePlaceholder } from 'uiSrc/components' import { addErrorNotification } from 'uiSrc/slices/app/notifications' -import { setConnectedInstanceId } from 'uiSrc/slices/instances' +import { setConnectedInstanceId } from 'uiSrc/slices/instances/instances' import { appInfoSelector } from 'uiSrc/slices/app/info' import { Instance } from 'uiSrc/slices/interfaces' import AddDatabaseContainer from 'uiSrc/pages/home/components/AddDatabases/AddDatabasesContainer' diff --git a/redisinsight/ui/src/pages/redisStack/components/protected-route/ProtectedRoute.tsx b/redisinsight/ui/src/pages/redisStack/components/protected-route/ProtectedRoute.tsx index 7674bf846c..093560c1e2 100644 --- a/redisinsight/ui/src/pages/redisStack/components/protected-route/ProtectedRoute.tsx +++ b/redisinsight/ui/src/pages/redisStack/components/protected-route/ProtectedRoute.tsx @@ -1,7 +1,7 @@ import React from 'react' import { Redirect, Route } from 'react-router-dom' import { useSelector } from 'react-redux' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { Pages } from 'uiSrc/constants' interface IProps { diff --git a/redisinsight/ui/src/pages/sentinelDatabases/SentinelDatabasesPage.spec.tsx b/redisinsight/ui/src/pages/sentinelDatabases/SentinelDatabasesPage.spec.tsx index 25834e3b8b..c7de5f2b4a 100644 --- a/redisinsight/ui/src/pages/sentinelDatabases/SentinelDatabasesPage.spec.tsx +++ b/redisinsight/ui/src/pages/sentinelDatabases/SentinelDatabasesPage.spec.tsx @@ -6,7 +6,7 @@ import SentinelDatabasesPage from './SentinelDatabasesPage' import SentinelDatabases from './components' import { Props as SentinelDatabasesProps } from './components/SentinelDatabases/SentinelDatabases' -jest.mock('uiSrc/slices/sentinel', () => ({ +jest.mock('uiSrc/slices/instances/sentinel', () => ({ sentinelSelector: jest.fn().mockReturnValue({ data: [{ status: 'success', diff --git a/redisinsight/ui/src/pages/sentinelDatabases/SentinelDatabasesPage.tsx b/redisinsight/ui/src/pages/sentinelDatabases/SentinelDatabasesPage.tsx index 676f8926b5..9358bf8ce0 100644 --- a/redisinsight/ui/src/pages/sentinelDatabases/SentinelDatabasesPage.tsx +++ b/redisinsight/ui/src/pages/sentinelDatabases/SentinelDatabasesPage.tsx @@ -11,7 +11,7 @@ import { useHistory } from 'react-router-dom' import { useDispatch, useSelector } from 'react-redux' import { Pages } from 'uiSrc/constants' -import { MAX_DATABASE_INDEX_NUMBER, setTitle } from 'uiSrc/utils' +import { setTitle } from 'uiSrc/utils' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { createMastersSentinelAction, @@ -19,7 +19,7 @@ import { resetLoadedSentinel, sentinelSelector, updateMastersSentinel, -} from 'uiSrc/slices/sentinel' +} from 'uiSrc/slices/instances/sentinel' import { LoadedSentinel, ModifiedSentinelMaster, @@ -235,7 +235,6 @@ const SentinelDatabasesPage = () => {
({ +jest.mock('uiSrc/slices/instances/sentinel', () => ({ sentinelSelector: jest.fn().mockReturnValue({ data: [{ status: 'success', diff --git a/redisinsight/ui/src/pages/sentinelDatabasesResult/SentinelDatabasesResultPage.tsx b/redisinsight/ui/src/pages/sentinelDatabasesResult/SentinelDatabasesResultPage.tsx index af2e07a3d4..74aa7beee9 100644 --- a/redisinsight/ui/src/pages/sentinelDatabasesResult/SentinelDatabasesResultPage.tsx +++ b/redisinsight/ui/src/pages/sentinelDatabasesResult/SentinelDatabasesResultPage.tsx @@ -24,8 +24,8 @@ import { updateMastersSentinel, createMastersSentinelAction, resetDataSentinel, -} from 'uiSrc/slices/sentinel' -import { MAX_DATABASE_INDEX_NUMBER, removeEmpty, setTitle } from 'uiSrc/utils' +} from 'uiSrc/slices/instances/sentinel' +import { removeEmpty, setTitle } from 'uiSrc/utils' import { ApiStatusCode, Pages } from 'uiSrc/constants' import { ApiEncryptionErrors } from 'uiSrc/constants/apiErrors' import { InputFieldSentinel } from 'uiSrc/components' @@ -299,7 +299,6 @@ const SentinelDatabasesResultPage = () => { ({ + ...jest.requireActual('uiSrc/slices/slowlog/slowlog'), + slowLogSelector: jest.fn().mockReturnValue({ + data: [], + config: null, + loading: false, + }), +})) + +const mockedData = [ + { + id: 0, + time: 1652429583, + durationUs: 56, + args: 'info', + source: '0.0.0.1:50834', + client: 'redisinsight-common-0' + }, + { + id: 1, + time: 1652429583, + durationUs: 11, + args: 'config get slowlog*', + source: '0.0.0.1:50834', + client: 'redisinsight-common-0' + } +] + +describe('SlowLogPage', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render empty slow log with empty data', () => { + (slowLogSelector as jest.Mock).mockImplementation(() => ({ + data: [], + config: null, + loading: false, + })) + + render() + expect(screen.getByTestId('empty-slow-log')).toBeTruthy() + }) + + it('should render slow log table with mocked data', () => { + (slowLogSelector as jest.Mock).mockImplementation(() => ({ + data: mockedData, + config: null, + loading: false, + })) + + render() + expect(screen.getByTestId('slowlog-table')).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/slowLog/SlowLogPage.tsx b/redisinsight/ui/src/pages/slowLog/SlowLogPage.tsx new file mode 100644 index 0000000000..7cfade3875 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/SlowLogPage.tsx @@ -0,0 +1,195 @@ +import { + EuiFlexGroup, + EuiFlexItem, + EuiSuperSelect, + EuiSuperSelectOption, + EuiText, + EuiTitle, +} from '@elastic/eui' +import { format } from 'date-fns' +import { minBy, toNumber } from 'lodash' +import React, { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import { AutoSizer } from 'react-virtualized' + +import InstanceHeader from 'uiSrc/components/instance-header' +import { DEFAULT_SLOWLOG_MAX_LEN } from 'uiSrc/constants' +import { DATE_FORMAT } from 'uiSrc/pages/slowLog/components/SlowLogTable/SlowLogTable' +import { convertNumberByUnits } from 'uiSrc/pages/slowLog/utils' +import { appAnalyticsInfoSelector } from 'uiSrc/slices/app/info' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { ConnectionType } from 'uiSrc/slices/interfaces' +import { + clearSlowLogAction, + fetchSlowLogsAction, + getSlowLogConfigAction, + slowLogConfigSelector, + slowLogSelector +} from 'uiSrc/slices/slowlog/slowlog' +import { sendPageViewTelemetry, sendEventTelemetry, TelemetryEvent, TelemetryPageView } from 'uiSrc/telemetry' +import { numberWithSpaces } from 'uiSrc/utils/numbers' + +import { SlowLog } from 'apiSrc/modules/slow-log/models' +import { EmptySlowLog, SlowLogTable, Actions } from './components' + +import styles from './styles.module.scss' + +const HIDE_TIMESTAMP_FROM_WIDTH = 850 +const DEFAULT_COUNT_VALUE = '50' +const MAX_COUNT_VALUE = '-1' +const countOptions: EuiSuperSelectOption[] = [ + { value: '10', inputDisplay: '10' }, + { value: '25', inputDisplay: '25' }, + { value: '50', inputDisplay: '50' }, + { value: '100', inputDisplay: '100' }, + { value: MAX_COUNT_VALUE, inputDisplay: 'Max available' }, +] + +const SlowLogPage = () => { + const { connectionType, name: connectedInstanceName } = useSelector(connectedInstanceSelector) + const { data, loading, durationUnit, config } = useSelector(slowLogSelector) + const { slowlogLogSlowerThan = 0, slowlogMaxLen } = useSelector(slowLogConfigSelector) + const { identified: analyticsIdentified } = useSelector(appAnalyticsInfoSelector) + const { instanceId } = useParams<{ instanceId: string }>() + + const [count, setCount] = useState(DEFAULT_COUNT_VALUE) + const [isPageViewSent, setIsPageViewSent] = useState(false) + + const dispatch = useDispatch() + + const lastTimestamp = minBy(data, 'time')?.time + + useEffect(() => { + getConfig() + }, []) + + useEffect(() => { + getSlowLogs() + }, [count]) + + useEffect(() => { + if (connectedInstanceName && !isPageViewSent && analyticsIdentified) { + sendPageView(instanceId) + } + }, [connectedInstanceName, isPageViewSent, analyticsIdentified]) + + const sendPageView = (instanceId: string) => { + sendPageViewTelemetry({ + name: TelemetryPageView.SLOWLOG_PAGE, + databaseId: instanceId + }) + setIsPageViewSent(true) + } + + const getSlowLogs = (maxLen?: number) => { + const countToSend = count === MAX_COUNT_VALUE + ? (maxLen || slowlogMaxLen || DEFAULT_SLOWLOG_MAX_LEN) + : toNumber(count) + + dispatch( + fetchSlowLogsAction(instanceId, countToSend, (data: SlowLog[]) => { + sendEventTelemetry({ + event: TelemetryEvent.SLOWLOG_LOADED, + eventData: { + databaseId: instanceId, + numberOfCommands: data.length + } + }) + }) + ) + } + + const getConfig = () => { + dispatch(getSlowLogConfigAction(instanceId)) + } + + const onClearSlowLogs = () => { + dispatch(clearSlowLogAction(instanceId, () => { + sendEventTelemetry({ + event: TelemetryEvent.SLOWLOG_CLEARED, + eventData: { + databaseId: instanceId + } + }) + })) + } + + const isEmptySlowLog = !data.length && !loading + + return ( + <> + +
+ + + +

Slow Log

+
+
+ + + {connectionType !== ConnectionType.Cluster && config && ( + + Execution time: {numberWithSpaces(convertNumberByUnits(slowlogLogSlowerThan, durationUnit))} +   + {durationUnit}, + Max length: {numberWithSpaces(slowlogMaxLen)} + + )} + +
+ + + {({ width }) => ( +
+ + + + + + {connectionType === ConnectionType.Cluster ? 'Display per node:' : 'Display up to:'} + + + + setCount(value)} + className={styles.countSelect} + popoverClassName={styles.countSelectWrapper} + data-testid="count-select" + /> + + {width > HIDE_TIMESTAMP_FROM_WIDTH && ( + + + ({data.length} entries + {lastTimestamp && (<> from {format(lastTimestamp * 1000, DATE_FORMAT)})}) + + + )} + + + + + + +
+ )} +
+ {isEmptySlowLog + ? + : } +
+ + ) +} + +export default SlowLogPage diff --git a/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.spec.tsx b/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.spec.tsx new file mode 100644 index 0000000000..ce466e7d0d --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.spec.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { mock } from 'ts-mockito' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' + +import Actions, { Props } from './Actions' + +const mockedProps = mock() + +describe('Actions', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call onClear after submit clear btn', () => { + const onClear = jest.fn() + render() + + fireEvent.click(screen.getByTestId('clear-btn')) + fireEvent.click(screen.getByTestId('reset-confirm-btn')) + + expect(onClear).toBeCalled() + }) + + it('should call onRefresh after submit refresh btn', () => { + const onRefresh = jest.fn() + render() + + fireEvent.click(screen.getByTestId('refresh-slowlog-btn')) + + expect(onRefresh).toBeCalled() + }) +}) diff --git a/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.tsx b/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.tsx new file mode 100644 index 0000000000..c8183b111f --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/Actions/Actions.tsx @@ -0,0 +1,210 @@ +import { + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + EuiSpacer, + EuiText, + EuiToolTip +} from '@elastic/eui' +import React, { useState } from 'react' +import { useSelector } from 'react-redux' +import cx from 'classnames' +import { useParams } from 'react-router-dom' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { DurationUnits } from 'uiSrc/constants' +import { slowLogSelector } from 'uiSrc/slices/slowlog/slowlog' +import AutoRefresh from 'uiSrc/pages/browser/components/auto-refresh' +import { Nullable } from 'uiSrc/utils' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import SlowLogConfig from '../SlowLogConfig' +import styles from './styles.module.scss' + +export interface Props { + width: number + isEmptySlowLog: boolean + durationUnit: Nullable + onClear: () => void + onRefresh: (maxLen?: number) => void +} + +const HIDE_REFRESH_LABEL_WIDTH = 850 + +const Actions = (props: Props) => { + const { isEmptySlowLog, durationUnit, width, onClear = () => {}, onRefresh } = props + const { instanceId } = useParams<{ instanceId: string }>() + const { name = '' } = useSelector(connectedInstanceSelector) + const { loading, lastRefreshTime } = useSelector(slowLogSelector) + + const [isPopoverClearOpen, setIsPopoverClearOpen] = useState(false) + const [isPopoverConfigOpen, setIsPopoverConfigOpen] = useState(false) + + const showClearPopover = () => { + setIsPopoverClearOpen((isPopoverClearOpen) => !isPopoverClearOpen) + } + + const closePopoverClear = () => { + setIsPopoverClearOpen(false) + } + const showConfigPopover = () => { + setIsPopoverConfigOpen((isPopoverConfigOpen) => !isPopoverConfigOpen) + } + + const closePopoverConfig = () => { + setIsPopoverConfigOpen(false) + } + + const handleClearClick = () => { + onClear() + closePopoverClear() + } + + const handleEnableAutoRefresh = (enableAutoRefresh: boolean, refreshRate: string) => { + sendEventTelemetry({ + event: enableAutoRefresh + ? TelemetryEvent.SLOWLOG_AUTO_REFRESH_ENABLED + : TelemetryEvent.SLOWLOG_AUTO_REFRESH_DISABLED, + eventData: { + databaseId: instanceId, + refreshRate: enableAutoRefresh ? +refreshRate : undefined + } + }) + } + + const handleChangeAutoRefreshRate = (enableAutoRefresh: boolean, refreshRate: string) => { + if (enableAutoRefresh) { + sendEventTelemetry({ + event: TelemetryEvent.SLOWLOG_AUTO_REFRESH_ENABLED, + eventData: { + databaseId: instanceId, + refreshRate: +refreshRate + } + }) + } + } + + const ToolTipContent = ( +
+ +
+ +

+ Clear Slow Log? +

+ + Slow Log will be cleared for  + {name} +
+ NOTE: This is server configuration +
+
+
+ handleClearClick()} + className={styles.popoverDeleteBtn} + data-testid="reset-confirm-btn" + > + Clear + +
+
+
+ ) + + return ( + + + HIDE_REFRESH_LABEL_WIDTH} + lastRefreshTime={lastRefreshTime} + containerClassName={styles.refreshContainer} + onRefresh={() => onRefresh()} + onEnableAutoRefresh={handleEnableAutoRefresh} + onChangeAutoRefreshRate={handleChangeAutoRefreshRate} + testid="refresh-slowlog-btn" + /> + + + {}} + panelClassName={cx('popover-without-top-tail', styles.configWrapper)} + button={( + showConfigPopover()} + data-testid="configure-btn" + > + Configure + + )} + > + + + + {!isEmptySlowLog && ( + + + showClearPopover()} + data-testid="clear-btn" + /> + + )} + > + {ToolTipContent} + + + )} + + + Slow Log is a list of slow operations for your Redis instance. These can be used + to troubleshoot performance issues. + + Each entry in the list displays the command, duration and timestamp. + Any transaction that exceeds slowlog-log-slower-than {durationUnit} are recorded up to a + maximum of slowlog-max-len after which older entries are discarded. + + )} + > + + + + + ) +} + +export default Actions diff --git a/redisinsight/ui/src/pages/slowLog/components/Actions/index.ts b/redisinsight/ui/src/pages/slowLog/components/Actions/index.ts new file mode 100644 index 0000000000..9e8e09c496 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/Actions/index.ts @@ -0,0 +1,3 @@ +import Actions from './Actions' + +export default Actions diff --git a/redisinsight/ui/src/pages/slowLog/components/Actions/styles.module.scss b/redisinsight/ui/src/pages/slowLog/components/Actions/styles.module.scss new file mode 100644 index 0000000000..a62e4dfa3d --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/Actions/styles.module.scss @@ -0,0 +1,61 @@ +.actions { + width: 370px; + + @media only screen and (max-width: 870px) { + width: 300px; + } + + .icon { + color: var(--iconsDefaultColor); + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: var(--iconsDefaultHoverColor); + } + + :global(.euiIcon) { + width: 18px; + height: 18px; + } + + .infoIcon { + width: 20px; + height: 20px; + } + } +} + +.popoverContainer { + display: flex; + align-items: flex-start; + + .warningIcon { + color: var(--euiColorWarningLight) !important; + margin: 2px 6px 2px; + } + + .popoverTitle { + font: normal normal 500 13px/22px Graphik; + color: var(--euiColorFullShade) !important; + } + + .popoverFooter { + display: flex; + justify-content: flex-end; + margin-top: 12px; + } + + .popoverDBName { + color: var(--htmlColor); + } +} + +.configWrapper { + padding: 24px !important; + box-shadow: 0px 3px 15px #00000099; + background-color: var(--euiColorLightestShade) !important; +} diff --git a/redisinsight/ui/src/pages/slowLog/components/EmptySlowLog/EmptySlowLog.spec.tsx b/redisinsight/ui/src/pages/slowLog/components/EmptySlowLog/EmptySlowLog.spec.tsx new file mode 100644 index 0000000000..e4570591f3 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/EmptySlowLog/EmptySlowLog.spec.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { DurationUnits } from 'uiSrc/constants' +import { render } from 'uiSrc/utils/test-utils' + +import EmptySlowLog from './EmptySlowLog' + +describe('EmptySlowLog', () => { + it('should render', () => { + expect(render( + + )).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/slowLog/components/EmptySlowLog/EmptySlowLog.tsx b/redisinsight/ui/src/pages/slowLog/components/EmptySlowLog/EmptySlowLog.tsx new file mode 100644 index 0000000000..c3cd70fb08 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/EmptySlowLog/EmptySlowLog.tsx @@ -0,0 +1,33 @@ +import { EuiText, EuiTitle } from '@elastic/eui' +import React from 'react' +import { DurationUnits } from 'uiSrc/constants' +import { convertNumberByUnits } from 'uiSrc/pages/slowLog/utils' +import { numberWithSpaces } from 'uiSrc/utils/numbers' + +import styles from '../styles.module.scss' + +export interface Props { + durationUnit: DurationUnits + slowlogLogSlowerThan: number +} + +const EmptySlowLog = (props: Props) => { + const { durationUnit, slowlogLogSlowerThan } = props + + return ( +
+
+ +

No Slow Logs found

+
+ + Either no commands exceeding  + {numberWithSpaces(convertNumberByUnits(slowlogLogSlowerThan, durationUnit))} {durationUnit} +  were found or Slow Log is disabled on the server. + +
+
+ ) +} + +export default EmptySlowLog diff --git a/redisinsight/ui/src/pages/slowLog/components/EmptySlowLog/index.ts b/redisinsight/ui/src/pages/slowLog/components/EmptySlowLog/index.ts new file mode 100644 index 0000000000..8e31a2ab10 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/EmptySlowLog/index.ts @@ -0,0 +1,3 @@ +import EmptySlowLog from './EmptySlowLog' + +export default EmptySlowLog diff --git a/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.spec.tsx b/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.spec.tsx new file mode 100644 index 0000000000..71d70a44b8 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.spec.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { cloneDeep } from 'lodash' +import { render, screen, fireEvent, mockedStore, cleanup } from 'uiSrc/utils/test-utils' +import { DEFAULT_SLOWLOG_MAX_LEN, DEFAULT_SLOWLOG_SLOWER_THAN } from 'uiSrc/constants' + +import SlowLogConfig, { Props } from './SlowLogConfig' + +const mockedProps = mock() + +const slowlogMaxLenMock = 123 +const slowlogLogSlowerThanMock = 1000 + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/slices/slowlog/slowlog', () => ({ + ...jest.requireActual('uiSrc/slices/slowlog/slowlog'), + slowLogConfigSelector: jest.fn().mockReturnValue({ + slowlogMaxLen: slowlogMaxLenMock, + slowlogLogSlowerThan: slowlogLogSlowerThanMock, + }), +})) + +describe('SlowLogConfig', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should change "slower-than-input" value properly', () => { + render() + fireEvent.change(screen.getByTestId('slower-than-input'), { target: { value: '123' } }) + expect(screen.getByTestId('slower-than-input')).toHaveValue('123') + }) + + it('should change "max-len-input" value properly', () => { + render() + fireEvent.change(screen.getByTestId('max-len-input'), { target: { value: '123' } }) + expect(screen.getByTestId('max-len-input')).toHaveValue('123') + }) + + it('btn Cancel should call "closePopover" and do not call "onRefresh"', () => { + const onRefresh = jest.fn() + const closePopover = jest.fn() + render() + fireEvent.change(screen.getByTestId('max-len-input'), { target: { value: '123' } }) + fireEvent.change(screen.getByTestId('slower-than-input'), { target: { value: '123' } }) + + fireEvent.click(screen.getByTestId('slowlog-config-cancel-btn')) + expect(closePopover).toBeCalled() + expect(onRefresh).not.toBeCalled() + }) + + it('btn Default should do not call "closePopover" and "onRefresh"', () => { + const onRefresh = jest.fn() + const closePopover = jest.fn() + render() + fireEvent.change(screen.getByTestId('max-len-input'), { target: { value: '123' } }) + fireEvent.change(screen.getByTestId('slower-than-input'), { target: { value: '123' } }) + + fireEvent.click(screen.getByTestId('slowlog-config-default-btn')) + expect(closePopover).not.toBeCalled() + expect(onRefresh).not.toBeCalled() + }) + + it('btn Default should reset form"', () => { + const onRefresh = jest.fn() + const closePopover = jest.fn() + render() + fireEvent.change(screen.getByTestId('max-len-input'), { target: { value: '12323' } }) + fireEvent.change(screen.getByTestId('slower-than-input'), { target: { value: '123223' } }) + + fireEvent.click(screen.getByTestId('slowlog-config-default-btn')) + expect(screen.getByTestId('max-len-input')).toHaveValue(`${DEFAULT_SLOWLOG_MAX_LEN}`) + expect(screen.getByTestId('slower-than-input')).toHaveValue(`${DEFAULT_SLOWLOG_SLOWER_THAN}`) + }) +}) diff --git a/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.tsx b/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.tsx new file mode 100644 index 0000000000..431c73a5b4 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/SlowLogConfig.tsx @@ -0,0 +1,244 @@ +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiSuperSelect, + EuiText, +} from '@elastic/eui' +import { toNumber } from 'lodash' +import React, { ChangeEvent, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import cx from 'classnames' +import { + DEFAULT_SLOWLOG_DURATION_UNIT, + DEFAULT_SLOWLOG_MAX_LEN, + DEFAULT_SLOWLOG_SLOWER_THAN, + DurationUnits, + DURATION_UNITS, + MINUS_ONE, +} from 'uiSrc/constants' +import { ConnectionType } from 'uiSrc/slices/interfaces' +import { ConfigDBStorageItem } from 'uiSrc/constants/storage' +import { setDBConfigStorageField } from 'uiSrc/services' +import { patchSlowLogConfigAction, slowLogConfigSelector, slowLogSelector } from 'uiSrc/slices/slowlog/slowlog' +import { errorValidateNegativeInteger, validateNumber } from 'uiSrc/utils' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { numberWithSpaces } from 'uiSrc/utils/numbers' +import { convertNumberByUnits } from '../../utils' +import styles from './styles.module.scss' + +export interface Props { + closePopover: () => void + onRefresh: (maxLen?: number) => void +} + +const SlowLogConfig = ({ closePopover, onRefresh }: Props) => { + const options = DURATION_UNITS + const { instanceId } = useParams<{ instanceId: string }>() + const { connectionType } = useSelector(connectedInstanceSelector) + const { loading, durationUnit: durationUnitStore } = useSelector(slowLogSelector) + const { + slowlogMaxLen = DEFAULT_SLOWLOG_MAX_LEN, + slowlogLogSlowerThan = DEFAULT_SLOWLOG_SLOWER_THAN, + } = useSelector(slowLogConfigSelector) + + const [durationUnit, setDurationUnit] = useState(durationUnitStore ?? DEFAULT_SLOWLOG_DURATION_UNIT) + const [maxLen, setMaxLen] = useState(`${slowlogMaxLen}`) + + const [slowerThan, setSlowerThan] = useState(slowlogLogSlowerThan !== MINUS_ONE + ? `${convertNumberByUnits(slowlogLogSlowerThan, durationUnit)}` + : `${MINUS_ONE}`) + + const dispatch = useDispatch() + + const onChangeUnit = (value: DurationUnits) => { + setDurationUnit(value) + } + + const handleDefault = () => { + setMaxLen(`${DEFAULT_SLOWLOG_MAX_LEN}`) + setSlowerThan(`${DEFAULT_SLOWLOG_SLOWER_THAN}`) + setDurationUnit(DEFAULT_SLOWLOG_DURATION_UNIT) + } + + const handleCancel = () => { + closePopover() + } + + const calculateSlowlogLogSlowerThan = (initSlowerThan: string) => { + if (initSlowerThan === '') { + return DEFAULT_SLOWLOG_SLOWER_THAN + } + if (initSlowerThan === `${MINUS_ONE}`) { + return MINUS_ONE + } + if (initSlowerThan === `${MINUS_ONE}`) { + return MINUS_ONE + } + return durationUnit === DurationUnits.microSeconds ? +initSlowerThan : +initSlowerThan * 1000 + } + + const handleSave = () => { + const slowlogLogSlowerThan = calculateSlowlogLogSlowerThan(slowerThan) + dispatch(patchSlowLogConfigAction( + instanceId, + { + slowlogMaxLen: maxLen ? toNumber(maxLen) : DEFAULT_SLOWLOG_MAX_LEN, + slowlogLogSlowerThan, + }, + durationUnit, + onSuccess + )) + } + + const onSuccess = () => { + setDBConfigStorageField(instanceId, ConfigDBStorageItem.slowLogDurationUnit, durationUnit) + + onRefresh(maxLen ? toNumber(maxLen) : DEFAULT_SLOWLOG_MAX_LEN) + closePopover() + } + + const disabledApplyBtn = () => (errorValidateNegativeInteger(`${slowerThan}`) && !!slowerThan) || loading + + const clusterContent = () => ( + <> + + Each node can have different Slow Log configuration in a clustered database. + + {'Use '} + CONFIG SET slowlog-log-slower-than + {' or '} + CONFIG SET slowlog-max-len + {' for a specific node in redis-cli to configure it.'} + + + + + Ok + + + ) + + const unitConverter = () => { + if (Number.isNaN(toNumber(slowerThan))) { + return `- ${DurationUnits.milliSeconds}` + } + + if (slowerThan === `${MINUS_ONE}`) { + return `-1 ${DurationUnits.milliSeconds}` + } + + if (durationUnit === DurationUnits.microSeconds) { + const value = numberWithSpaces(convertNumberByUnits(toNumber(slowerThan), DurationUnits.milliSeconds)) + return `${value} ${DurationUnits.milliSeconds}` + } + + if (durationUnit === DurationUnits.milliSeconds) { + const value = numberWithSpaces(toNumber(slowerThan) * 1000) + return `${value} ${DurationUnits.microSeconds}` + } + return null + } + + return ( +
+ {connectionType === ConnectionType.Cluster && (clusterContent())} + {connectionType !== ConnectionType.Cluster && ( + <> + + + <> +
slowlog-log-slower-than
+
+ ) => { + setSlowerThan(validateNumber(e.target.value.trim(), Infinity, -1)) + }} + placeholder={`${convertNumberByUnits(DEFAULT_SLOWLOG_SLOWER_THAN, durationUnit)}`} + autoComplete="off" + data-testid="slower-than-input" + /> + +
+
{unitConverter()}
+
+ Execution time to exceed in order to log the command. +
+ -1 disables Slow Log. 0 logs each command. +
+
+
+ +
+ + <> +
slowlog-max-len
+
+ ) => { setMaxLen(validateNumber(e.target.value.trim())) }} + autoComplete="off" + data-testid="max-len-input" + /> +
+ The length of the Slow Log. When a new command is logged the oldest +
+ one is removed from the queue of logged commands. +
+
+ +
+ +
+ +
+
NOTE: This is server configuration
+
+ + Default + + + Cancel + + + Save + +
+
+ + )} +
+ ) +} + +export default SlowLogConfig diff --git a/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/index.ts b/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/index.ts new file mode 100644 index 0000000000..35cca8968e --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/index.ts @@ -0,0 +1,3 @@ +import SlowLogConfig from './SlowLogConfig' + +export default SlowLogConfig diff --git a/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/styles.module.scss b/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/styles.module.scss new file mode 100644 index 0000000000..1b0f335fac --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/SlowLogConfig/styles.module.scss @@ -0,0 +1,127 @@ +.container { + width: 550px; + height: 280px; + background-color: var(--euiColorLightestShade); + border-radius: 4px; +} + +.containerCluster { + width: 394px; + height: 150px; +} + +.selectWrapper { + display: inline-block !important; + width: 60px !important; + margin-top: -4px; + + :global(.euiFormControlLayout__childrenWrapper) { + height: 42px !important; + width: 60px; + } +} + +.input { + width: 120px !important; +} + +.formRow { + width: 600px !important; + max-width: 600px !important; + padding-bottom: 2px; + + :global(.euiFormRow__fieldWrapper) { + width: 100% !important; + min-width: 100% !important; + + :global(.euiFormControlLayout) { + display: inline-block; + width: 120px; + } + } +} + +.rowFields { + display: inline-block; + width: 400px; +} + +.rowLabel { + display: inline-block; + width: 156px; + padding-right: 12px; + vertical-align: top; + font-size: 13px; + padding-top: 14px; +} + +.helpText { + display: inline-block; + font-size: 12px; + line-height: 18px; + letter-spacing: -0.12px; + color: var(--euiColorMediumShade); + padding-top: 6px; + + div { + display: inline-block; + padding-bottom: 6px; + width: 100%; + } +} + +.footer { + padding-top: 6px; + + .helpText { + padding-top: 12px; + } +} + +.actions { + display: inline-block; + right: 24px; + position: absolute; + + :global { + .euiButton, + .euiButtonEmpty { + margin-right: 12px !important; + width: 100px; + height: 42px; + + .euiButtonEmpty__text { + color: var(--euiColorFullShade) !important; + } + } + } +} + +.clusterText :global(.euiTextColor) { + font-size: 13px !important; + line-height: 18px !important; + letter-spacing: -0.13px !important; + + a { + color: var(--euiColorFullShade) !important; + } + + code { + font-size: 13px; + line-height: 18px; + padding-left: 4px; + padding-right: 4px; + color: var(--inputTextColor); + + &:last-of-type { + padding-left: 0 !important; + } + } +} + +.clusterBtn { + margin-top: 8px; + position: absolute; + right: 24px; + bottom: 18px; +} diff --git a/redisinsight/ui/src/pages/slowLog/components/SlowLogTable/SlowLogTable.spec.tsx b/redisinsight/ui/src/pages/slowLog/components/SlowLogTable/SlowLogTable.spec.tsx new file mode 100644 index 0000000000..fc7fc26a46 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/SlowLogTable/SlowLogTable.spec.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { mock } from 'ts-mockito' +import { render, screen } from 'uiSrc/utils/test-utils' + +import SlowLogTable, { Props } from './SlowLogTable' + +const mockedProps = mock() + +const mockedData = [ + { + id: 0, + time: 1652429583, + durationUs: 56, + args: 'info', + source: '0.0.0.1:50834', + client: 'redisinsight-common-0' + }, + { + id: 1, + time: 1652429583, + durationUs: 11, + args: 'config get slowlog*', + source: '0.0.0.1:50834', + client: 'redisinsight-common-0' + } +] + +describe('SlowLogTable', () => { + it('should render', () => { + expect(render()) + .toBeTruthy() + }) + + it('should render data', () => { + expect(render()) + .toBeTruthy() + expect(screen.getAllByLabelText(/row/)).toHaveLength(mockedData.length) + }) +}) diff --git a/redisinsight/ui/src/pages/slowLog/components/SlowLogTable/SlowLogTable.tsx b/redisinsight/ui/src/pages/slowLog/components/SlowLogTable/SlowLogTable.tsx new file mode 100644 index 0000000000..42562a0042 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/SlowLogTable/SlowLogTable.tsx @@ -0,0 +1,102 @@ +import { EuiText, EuiToolTip } from '@elastic/eui' +import { format } from 'date-fns' +import React, { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' +import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' +import { DurationUnits, SortOrder, TableCellAlignment, TableCellTextAlignment } from 'uiSrc/constants' +import { convertNumberByUnits } from 'uiSrc/pages/slowLog/utils' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { numberWithSpaces } from 'uiSrc/utils/numbers' + +import styles from '../styles.module.scss' + +export const DATE_FORMAT = 'HH:mm:ss d LLL yyyy' + +export interface Props { + items: any + loading: boolean + durationUnit: DurationUnits +} + +const sortByTimeStamp = (items = [], order: SortOrder) => + [...items].sort((a: any, b: any) => (order === SortOrder.DESC ? b.time - a.time : a.time - b.time)) + +const SlowLogTable = (props: Props) => { + const { items = [], loading = false, durationUnit } = props + const [table, setTable] = useState([]) + const [sortedColumnName, setSortedColumnName] = useState('time') + const [sortedColumnOrder, setSortedColumnOrder] = useState(SortOrder.DESC) + + const { instanceId } = useParams<{ instanceId: string }>() + const sortedColumn = { + column: sortedColumnName, + order: sortedColumnOrder, + } + + useEffect(() => { + setTable(sortByTimeStamp(items, sortedColumnOrder)) + }, [items, sortedColumnOrder]) + + const columns: ITableColumn[] = [ + { + id: 'time', + label: 'Timestamp', + absoluteWidth: 190, + minWidth: 190, + isSortable: true, + render: (timestamp) => {format(timestamp * 1000, DATE_FORMAT)} + }, + { + id: 'durationUs', + label: `Duration, ${durationUnit}`, + minWidth: 110, + absoluteWidth: 'auto', + textAlignment: TableCellTextAlignment.Right, + alignment: TableCellAlignment.Right, + render: (duration) => {numberWithSpaces(convertNumberByUnits(duration, durationUnit))} + }, + { + id: 'args', + label: 'Command', + absoluteWidth: 'auto', + render: (command) => ( + + {command} + + ) + }, + ] + + const onChangeSorting = (column: any, order: SortOrder) => { + setSortedColumnName(column) + setSortedColumnOrder(order) + sendEventTelemetry({ + event: TelemetryEvent.SLOWLOG_SORTED, + eventData: { + databaseId: instanceId, + timestamp: order + } + }) + } + + return ( +
+ +
+ ) +} + +export default SlowLogTable diff --git a/redisinsight/ui/src/pages/slowLog/components/SlowLogTable/index.ts b/redisinsight/ui/src/pages/slowLog/components/SlowLogTable/index.ts new file mode 100644 index 0000000000..43e595a581 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/SlowLogTable/index.ts @@ -0,0 +1,3 @@ +import SlowLogTable from './SlowLogTable' + +export default SlowLogTable diff --git a/redisinsight/ui/src/pages/slowLog/components/index.ts b/redisinsight/ui/src/pages/slowLog/components/index.ts new file mode 100644 index 0000000000..bdfaa7adee --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/index.ts @@ -0,0 +1,9 @@ +import SlowLogTable from './SlowLogTable' +import EmptySlowLog from './EmptySlowLog' +import Actions from './Actions' + +export { + SlowLogTable, + EmptySlowLog, + Actions +} diff --git a/redisinsight/ui/src/pages/slowLog/components/styles.module.scss b/redisinsight/ui/src/pages/slowLog/components/styles.module.scss new file mode 100644 index 0000000000..983f2e4c81 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/components/styles.module.scss @@ -0,0 +1,61 @@ +.tableWrapper { + flex-grow: 1; + height: calc(100% - 100px); + + @media screen and (max-width: 1024px) { + height: calc(100% - 120px); + } + + :global { + .ReactVirtualized__Table__row { + border: 1px solid var(--tableDarkestBorderColor) !important; + + &:first-of-type { + border-top: 0 !important; + } + + &:not(:last-of-type) { + border-bottom: 0 !important; + } + } + + .ReactVirtualized__Table__rowColumn { + &:not(:last-of-type) { + border-right: 1px solid var(--tableDarkestBorderColor) !important; + } + } + } + + .commandTooltip { + max-width: 100%; + } + + .commandText { + display: block; + text-overflow: ellipsis; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + line-height: 1.4; + } +} + +.noSlowLogWrapper { + border: 1px solid var(--tableDarkestBorderColor); + min-height: 152px; + width: 100%; + display: flex; + align-items: center; + padding: 18px; + + .noFoundTitle { + font: normal normal 500 18px/24px Graphik; + margin-bottom: 12px; + } + + .noSlowLogText { + margin: 0 auto; + max-width: 882px; + width: 100%; + } +} diff --git a/redisinsight/ui/src/pages/slowLog/index.ts b/redisinsight/ui/src/pages/slowLog/index.ts new file mode 100644 index 0000000000..a18f8755df --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/index.ts @@ -0,0 +1,3 @@ +import SlowLogPage from './SlowLogPage' + +export default SlowLogPage diff --git a/redisinsight/ui/src/pages/slowLog/styles.module.scss b/redisinsight/ui/src/pages/slowLog/styles.module.scss new file mode 100644 index 0000000000..4d60d71054 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/styles.module.scss @@ -0,0 +1,25 @@ +.main { + margin: 0 16px 0; + height: calc(100% - 70px); + background-color: var(--euiColorEmptyShade); + padding: 18px; + + .title { + font-size: 24px; + } + + .actionsLine { + margin-bottom: 12px; + } + + .countSelectWrapper { + height: 30px; + } + + .countSelect { + width: 86px; + height: 30px; + padding-left: 12px; + padding-right: 32px; + } +} diff --git a/redisinsight/ui/src/pages/slowLog/utils.ts b/redisinsight/ui/src/pages/slowLog/utils.ts new file mode 100644 index 0000000000..ed94af1938 --- /dev/null +++ b/redisinsight/ui/src/pages/slowLog/utils.ts @@ -0,0 +1,9 @@ +import { DurationUnits } from 'uiSrc/constants' + +// convert from microSeconds +export const convertNumberByUnits = (number: number, unit: DurationUnits): number => { + if (unit === DurationUnits.milliSeconds) { + return number / 1000 + } + return number +} diff --git a/redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx b/redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx index 2781f65118..99d6f0ba71 100644 --- a/redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx +++ b/redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx @@ -4,7 +4,7 @@ import { useParams } from 'react-router-dom' import { formatLongName, getDbIndex, setTitle } from 'uiSrc/utils' import { PageNames } from 'uiSrc/constants' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { setLastPageContext } from 'uiSrc/slices/app/context' import { loadPluginsAction } from 'uiSrc/slices/app/plugins' import { sendPageViewTelemetry, TelemetryPageView } from 'uiSrc/telemetry' diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.spec.tsx b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.spec.tsx index 02304e2bc6..3c58303f45 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.spec.tsx @@ -11,7 +11,7 @@ import { waitFor, } from 'uiSrc/utils/test-utils' import QueryWrapper, { Props as QueryProps } from 'uiSrc/components/query' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { sendWBCommandAction } from 'uiSrc/slices/workbench/wb-results' import { getWBGuides } from 'uiSrc/slices/workbench/wb-guides' import { getWBTutorials } from 'uiSrc/slices/workbench/wb-tutorials' @@ -49,8 +49,8 @@ jest.mock('uiSrc/services', () => ({ }, })) -jest.mock('uiSrc/slices/instances', () => ({ - ...jest.requireActual('uiSrc/slices/instances'), +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), connectedInstanceSelector: jest.fn().mockReturnValue({ id: '123', connectionType: 'STANDALONE', diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx index 057290f3ac..d5de10fb25 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx @@ -21,7 +21,7 @@ import { fetchWBCommandAction, } from 'uiSrc/slices/workbench/wb-results' import { ConnectionType, Instance, IPluginVisualization } from 'uiSrc/slices/interfaces' -import { initialState as instanceInitState, connectedInstanceSelector } from 'uiSrc/slices/instances' +import { initialState as instanceInitState, connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { ClusterNodeRole } from 'uiSrc/slices/interfaces/cli' import { cliSettingsSelector, fetchBlockingCliCommandsAction } from 'uiSrc/slices/cli/cli-settings' diff --git a/redisinsight/ui/src/services/storage.ts b/redisinsight/ui/src/services/storage.ts index e49a036de8..622ea02b68 100644 --- a/redisinsight/ui/src/services/storage.ts +++ b/redisinsight/ui/src/services/storage.ts @@ -1,4 +1,5 @@ import { isObjectLike } from 'lodash' +import BrowserStorageItem from '../constants/storage' class StorageService { private storage: Storage @@ -48,4 +49,31 @@ class StorageService { export const localStorageService = new StorageService(localStorage) export const sessionStorageService = new StorageService(sessionStorage) +export const getDBConfigStorageField = (instanceId: string, field: string = '') => { + try { + return localStorageService.get(BrowserStorageItem.dbConfig + instanceId)?.[field] + } catch (e) { + return null + } +} + +export const setDBConfigStorageField = (instanceId: string, field: string = '', value?: any) => { + try { + const config = localStorageService.get(BrowserStorageItem.dbConfig + instanceId) || {} + + if (value === undefined) { + delete config[field] + localStorageService.set(BrowserStorageItem.dbConfig + instanceId, config) + return + } + + localStorageService.set(BrowserStorageItem.dbConfig + instanceId, { + ...config, + [field]: value + }) + } catch (e) { + console.error(e) + } +} + export default StorageService diff --git a/redisinsight/ui/src/slices/app/plugins.ts b/redisinsight/ui/src/slices/app/plugins.ts index 072f321340..6b572a68af 100644 --- a/redisinsight/ui/src/slices/app/plugins.ts +++ b/redisinsight/ui/src/slices/app/plugins.ts @@ -10,8 +10,8 @@ import { import { apiService } from 'uiSrc/services' import { ApiEndpoints } from 'uiSrc/constants' import { IPlugin, PluginsResponse, StateAppPlugins } from 'uiSrc/slices/interfaces' -import { SendCommandResponse } from 'src/modules/cli/dto/cli.dto' -import { PluginState } from 'src/modules/workbench/models/plugin-state' +import { SendCommandResponse } from 'apiSrc/modules/cli/dto/cli.dto' +import { PluginState } from 'apiSrc/modules/workbench/models/plugin-state' import { AppDispatch, RootState } from '../store' diff --git a/redisinsight/ui/src/slices/hash.ts b/redisinsight/ui/src/slices/browser/hash.ts similarity index 87% rename from redisinsight/ui/src/slices/hash.ts rename to redisinsight/ui/src/slices/browser/hash.ts index 7d82cff97d..3f767acd87 100644 --- a/redisinsight/ui/src/slices/hash.ts +++ b/redisinsight/ui/src/slices/browser/hash.ts @@ -2,7 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { cloneDeep, remove, isNull } from 'lodash' import { apiService } from 'uiSrc/services' import { ApiEndpoints, KeyTypes } from 'uiSrc/constants' -import { getApiErrorMessage, getUrl, isStatusSuccessful } from 'uiSrc/utils' +import { getApiErrorMessage, getUrl, isStatusSuccessful, Maybe } from 'uiSrc/utils' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' import successMessages from 'uiSrc/components/notifications/success-messages' @@ -18,9 +18,9 @@ import { refreshKeyInfoAction, updateSelectedKeyRefreshTime, } from './keys' -import { AppDispatch, RootState } from './store' -import { StateHash } from './interfaces' -import { addErrorNotification, addMessageNotification } from './app/notifications' +import { AppDispatch, RootState } from '../store' +import { StateHash } from '../interfaces' +import { addErrorNotification, addMessageNotification } from '../app/notifications' export const initialState: StateHash = { loading: false, @@ -46,12 +46,17 @@ const hashSlice = createSlice({ reducers: { setHashInitialState: () => initialState, // load Hash fields - loadHashFields: (state, { payload }: PayloadAction) => { + loadHashFields: (state, { payload: [match = '', resetData = true] }: PayloadAction<[string, Maybe]>) => { state.loading = true state.error = '' + + if (resetData) { + state.data = initialState.data + } + state.data = { - ...initialState.data, - match: payload || '*', + ...state.data, + match: match || '*' } }, loadHashFieldsSuccess: ( @@ -192,7 +197,7 @@ export function fetchHashFields( onSuccess?: (data: GetHashFieldsResponse) => void, ) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { - dispatch(loadHashFields(isNull(match) ? '*' : match)) + dispatch(loadHashFields([isNull(match) ? '*' : match, true])) try { const state = stateInit() @@ -223,11 +228,11 @@ export function fetchHashFields( } // Asynchronous thunk actions -export function refreshHashFieldsAction(key: string = '') { +export function refreshHashFieldsAction(key: string = '', resetData?: boolean) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { const state = stateInit() const { match } = state.browser.hash.data - dispatch(loadHashFields(match || '*')) + dispatch(loadHashFields([match || '*', resetData])) try { const { data, status } = await apiService.post( @@ -290,7 +295,7 @@ export function fetchMoreHashFields( } // Asynchronous thunk actions -export function deleteHashFields(key: string, fields: string[]) { +export function deleteHashFields(key: string, fields: string[], onSuccessAction?: () => void,) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { dispatch(removeHashFields()) try { @@ -309,18 +314,7 @@ export function deleteHashFields(key: string, fields: string[]) { ) const newTotalValue = state.browser.hash.data.total - data.affected if (isStatusSuccessful(status)) { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - state.browser.keys?.viewType, - TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED - ), - eventData: { - databaseId: state.connections.instances?.connectedInstance?.id, - keyType: KeyTypes.Hash, - numberOfRemoved: fields.length, - } - }) + onSuccessAction?.() dispatch(removeHashFieldsSuccess()) dispatch(removeFieldsFromList(fields)) if (newTotalValue > 0) { @@ -364,18 +358,6 @@ export function addHashFieldsAction( data ) if (isStatusSuccessful(status)) { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - state.browser.keys?.viewType, - TelemetryEvent.BROWSER_KEY_VALUE_ADDED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_ADDED - ), - eventData: { - databaseId: state.connections.instances?.connectedInstance?.id, - keyType: KeyTypes.Hash, - numberOfAdded: data.fields.length, - } - }) if (onSuccessAction) { onSuccessAction() } diff --git a/redisinsight/ui/src/slices/keys.ts b/redisinsight/ui/src/slices/browser/keys.ts similarity index 93% rename from redisinsight/ui/src/slices/keys.ts rename to redisinsight/ui/src/slices/browser/keys.ts index 1e12145fa8..1752a41526 100644 --- a/redisinsight/ui/src/slices/keys.ts +++ b/redisinsight/ui/src/slices/browser/keys.ts @@ -1,6 +1,6 @@ import { createSlice } from '@reduxjs/toolkit' -import { cloneDeep, remove, get } from 'lodash' -import axios, { CancelTokenSource } from 'axios' +import { cloneDeep, remove, get, isUndefined } from 'lodash' +import axios, { AxiosError, CancelTokenSource } from 'axios' import { apiService, localStorageService } from 'uiSrc/services' import { ApiEndpoints, BrowserStorageItem, KeyTypes, SortOrder } from 'uiSrc/constants' import { @@ -10,6 +10,7 @@ import { parseKeysListResponse, getUrl, isStatusSuccessful, + Maybe, } from 'uiSrc/utils' import { DEFAULT_SEARCH_MATCH, SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent, getAdditionalAddedEventData, getMatchType } from 'uiSrc/telemetry' @@ -22,15 +23,17 @@ import { CreateRejsonRlWithExpireDto, CreateSetWithExpireDto, } from 'apiSrc/modules/browser/dto' -import { AppDispatch, RootState } from './store' +import { CreateStreamDto } from 'apiSrc/modules/browser/dto/stream.dto' import { fetchString } from './string' import { setZsetInitialState, fetchZSetMembers } from './zset' import { fetchSetMembers } from './set' import { fetchReJSON } from './rejson' import { setHashInitialState, fetchHashFields } from './hash' import { setListInitialState, fetchListElements } from './list' -import { addErrorNotification, addMessageNotification } from './app/notifications' -import { KeysStore, KeyViewType } from './interfaces/keys' +import { fetchStreamEntries } from './stream' +import { addErrorNotification, addMessageNotification } from '../app/notifications' +import { KeysStore, KeyViewType } from '../interfaces/keys' +import { AppDispatch, RootState } from '../store' export const initialState: KeysStore = { loading: false, @@ -39,6 +42,7 @@ export const initialState: KeysStore = { search: '', isSearched: false, isFiltered: false, + isBrowserFullScreen: false, viewType: localStorageService?.get(BrowserStorageItem.browserViewType) ?? KeyViewType.Browser, data: { total: 0, @@ -70,7 +74,6 @@ const keysSlice = createSlice({ reducers: { // load Keys loadKeys: (state) => { - state.data = initialState.data state.loading = true state.error = '' }, @@ -290,6 +293,14 @@ const keysSlice = createSlice({ // state.data.keys = [] state.data.keys.length = 0 }, + + toggleBrowserFullScreen: (state, { payload }: { payload: Maybe }) => { + if (!isUndefined(payload)) { + state.isBrowserFullScreen = payload + return + } + state.isBrowserFullScreen = !state.isBrowserFullScreen + } }, }) @@ -327,6 +338,7 @@ export const { resetKeyInfo, resetKeys, resetKeysData, + toggleBrowserFullScreen } = keysSlice.actions // A selector @@ -490,7 +502,7 @@ export function fetchMoreKeys(cursor: string, count: number) { } // Asynchronous thunk action -export function fetchKeyInfo(key: string) { +export function fetchKeyInfo(key: string, resetData?: boolean) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { dispatch(defaultSelectedKeyAction()) @@ -513,26 +525,35 @@ export function fetchKeyInfo(key: string) { } if (data.type === KeyTypes.Hash) { - dispatch(fetchHashFields(key, 0, SCAN_COUNT_DEFAULT, '*')) + dispatch(fetchHashFields(key, 0, SCAN_COUNT_DEFAULT, '*', resetData)) } if (data.type === KeyTypes.List) { - dispatch(fetchListElements(key, 0, SCAN_COUNT_DEFAULT)) + dispatch(fetchListElements(key, 0, SCAN_COUNT_DEFAULT, resetData)) } if (data.type === KeyTypes.String) { - dispatch(fetchString(key)) + dispatch(fetchString(key, resetData)) } if (data.type === KeyTypes.ZSet) { dispatch( - fetchZSetMembers(key, 0, SCAN_COUNT_DEFAULT, SortOrder.ASC) + fetchZSetMembers(key, 0, SCAN_COUNT_DEFAULT, SortOrder.ASC, resetData) ) } if (data.type === KeyTypes.Set) { - dispatch(fetchSetMembers(key, 0, SCAN_COUNT_DEFAULT, '*')) + dispatch(fetchSetMembers(key, 0, SCAN_COUNT_DEFAULT, '*', resetData)) } if (data.type === KeyTypes.ReJSON) { - dispatch(fetchReJSON(key, '.')) - } - } catch (error) { + dispatch(fetchReJSON(key, '.', resetData)) + } + if (data.type === KeyTypes.Stream) { + dispatch(fetchStreamEntries( + key, + SCAN_COUNT_DEFAULT, + SortOrder.DESC, + resetData + )) + } + } catch (_err) { + const error = _err as AxiosError const errorMessage = getApiErrorMessage(error) dispatch(addErrorNotification(error)) dispatch(defaultSelectedKeyActionFailure(errorMessage)) @@ -591,7 +612,6 @@ function addTypedKey( onSuccessAction() } dispatch(addKeySuccess()) - dispatch(fetchKeyInfo(data.keyName)) dispatch( addMessageNotification(successMessages.ADDED_NEW_KEY(data.keyName)) ) @@ -678,6 +698,16 @@ export function addReJSONKey( return addTypedKey(data, endpoint, onSuccessAction, onFailAction) } +// Asynchronous thunk action +export function addStreamKey( + data: CreateStreamDto, + onSuccessAction?: () => void, + onFailAction?: () => void +) { + const endpoint = ApiEndpoints.STREAMS + return addTypedKey(data, endpoint, onSuccessAction, onFailAction) +} + // Asynchronous thunk action export function deleteKeyAction(key: string, onSuccessAction?: () => void) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { diff --git a/redisinsight/ui/src/slices/list.ts b/redisinsight/ui/src/slices/browser/list.ts similarity index 90% rename from redisinsight/ui/src/slices/list.ts rename to redisinsight/ui/src/slices/browser/list.ts index 46e663215d..71e6beffad 100644 --- a/redisinsight/ui/src/slices/list.ts +++ b/redisinsight/ui/src/slices/browser/list.ts @@ -9,6 +9,7 @@ import { Nullable, getApiErrorMessage, isStatusSuccessful, + Maybe, } from 'uiSrc/utils' import { SetListElementDto, @@ -21,9 +22,9 @@ import { import successMessages from 'uiSrc/components/notifications/success-messages' import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' -import { StateList } from './interfaces/list' -import { AppDispatch, RootState } from './store' -import { addErrorNotification, addMessageNotification } from './app/notifications' +import { StateList } from '../interfaces/list' +import { AppDispatch, RootState } from '../store' +import { addErrorNotification, addMessageNotification } from '../app/notifications' import { refreshKeyInfoAction, fetchKeyInfo, @@ -57,10 +58,13 @@ const listSlice = createSlice({ reducers: { setListInitialState: () => initialState, // load List elements - loadListElements: (state) => { + loadListElements: (state, { payload: resetData = true }: PayloadAction>) => { state.loading = true state.error = '' - state.data = initialState.data + + if (resetData) { + state.data = initialState.data + } }, loadListElementsSuccess: ( state, @@ -215,9 +219,9 @@ export const updateListValueStateSelector = (state: RootState) => export default listSlice.reducer // Asynchronous thunk actions -export function fetchListElements(key: string, offset: number, count: number) { +export function fetchListElements(key: string, offset: number, count: number, resetData?: boolean) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { - dispatch(loadListElements()) + dispatch(loadListElements(resetData)) try { const state = stateInit() @@ -314,13 +318,13 @@ export function fetchSearchingListElementAction( } // Asynchronous thunk actions -export function refreshListElementsAction(key: string = '') { +export function refreshListElementsAction(key: string = '', resetData?: boolean) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { const state = stateInit() const { searchedIndex } = state.browser.list.data if (isNull(searchedIndex)) { - dispatch(fetchListElements(key, 0, SCAN_COUNT_DEFAULT)) + dispatch(fetchListElements(key, 0, SCAN_COUNT_DEFAULT, resetData)) } else { dispatch(fetchSearchingListElementAction(key, searchedIndex)) } @@ -392,18 +396,6 @@ export function insertListElementsAction( onSuccessAction?.() dispatch(insertListElementsSuccess()) dispatch(fetchKeyInfo(data.keyName)) - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - state.browser.keys?.viewType, - TelemetryEvent.BROWSER_KEY_VALUE_ADDED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_ADDED - ), - eventData: { - databaseId: state.connections.instances?.connectedInstance?.id, - keyType: KeyTypes.List, - numberOfAdded: 1, - } - }) } } catch (error) { const errorMessage = getApiErrorMessage(error) @@ -433,18 +425,6 @@ export function deleteListElementsAction( { data } ) if (isStatusSuccessful(status)) { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - state.browser.keys?.viewType, - TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED - ), - eventData: { - databaseId: state.connections.instances?.connectedInstance?.id, - keyType: KeyTypes.List, - numberOfRemoved: data.count, - } - }) onSuccessAction?.() dispatch(deleteListElementsSuccess()) if (state.browser.list.data?.total - data.count > 0) { diff --git a/redisinsight/ui/src/slices/rejson.ts b/redisinsight/ui/src/slices/browser/rejson.ts similarity index 94% rename from redisinsight/ui/src/slices/rejson.ts rename to redisinsight/ui/src/slices/browser/rejson.ts index e0551de4ce..4ba957681c 100644 --- a/redisinsight/ui/src/slices/rejson.ts +++ b/redisinsight/ui/src/slices/browser/rejson.ts @@ -1,5 +1,4 @@ -import { createSlice } from '@reduxjs/toolkit' -import { cloneDeep } from 'lodash' +import { createSlice, PayloadAction } from '@reduxjs/toolkit' import axios, { CancelTokenSource } from 'axios' import * as jsonpath from 'jsonpath' @@ -10,6 +9,7 @@ import { getApiErrorMessage, getUrl, isStatusSuccessful, + Maybe, Nullable, } from 'uiSrc/utils' import successMessages from 'uiSrc/components/notifications/success-messages' @@ -19,17 +19,17 @@ import { RemoveRejsonRlResponse, } from 'apiSrc/modules/browser/dto/rejson-rl.dto' -import { InitialStateRejson } from './interfaces' import { refreshKeyInfoAction } from './keys' -import { addErrorNotification, addMessageNotification } from './app/notifications' -import { AppDispatch, RootState } from './store' +import { InitialStateRejson } from '../interfaces' +import { addErrorNotification, addMessageNotification } from '../app/notifications' +import { AppDispatch, RootState } from '../store' export const initialState: InitialStateRejson = { loading: false, error: '', data: { downloaded: false, - data: null, + data: undefined, type: '', }, } @@ -40,10 +40,13 @@ const rejsonSlice = createSlice({ initialState, reducers: { // load reJSON part - loadRejsonBranch: (state) => { + loadRejsonBranch: (state, { payload: resetData = true }: PayloadAction>) => { state.loading = true state.error = '' - state.data = cloneDeep(initialState.data) + + if (resetData) { + state.data = initialState.data + } }, loadRejsonBranchSuccess: (state, { payload }) => { state.data = payload @@ -120,9 +123,9 @@ export default rejsonSlice.reducer export let sourceRejson: Nullable = null // Asynchronous thunk action -export function fetchReJSON(key: string, path = '.') { +export function fetchReJSON(key: string, path = '.', resetData?: boolean) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { - dispatch(loadRejsonBranch()) + dispatch(loadRejsonBranch(resetData)) try { sourceRejson?.cancel?.() diff --git a/redisinsight/ui/src/slices/set.ts b/redisinsight/ui/src/slices/browser/set.ts similarity index 85% rename from redisinsight/ui/src/slices/set.ts rename to redisinsight/ui/src/slices/browser/set.ts index 18fa2b5594..3d89855bae 100644 --- a/redisinsight/ui/src/slices/set.ts +++ b/redisinsight/ui/src/slices/browser/set.ts @@ -3,7 +3,7 @@ import { remove } from 'lodash' import { apiService } from 'uiSrc/services' import { ApiEndpoints, KeyTypes } from 'uiSrc/constants' -import { getApiErrorMessage, getUrl, isStatusSuccessful } from 'uiSrc/utils' +import { getApiErrorMessage, getUrl, isStatusSuccessful, Maybe } from 'uiSrc/utils' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { AddMembersToSetDto, @@ -19,9 +19,9 @@ import { refreshKeyInfoAction, updateSelectedKeyRefreshTime, } from './keys' -import { AppDispatch, RootState } from './store' -import { InitialStateSet } from './interfaces' -import { addErrorNotification, addMessageNotification } from './app/notifications' +import { AppDispatch, RootState } from '../store' +import { InitialStateSet } from '../interfaces' +import { addErrorNotification, addMessageNotification } from '../app/notifications' export const initialState: InitialStateSet = { loading: false, @@ -42,12 +42,17 @@ const setSlice = createSlice({ initialState, reducers: { // load Set members - loadSetMembers: (state, { payload }) => { + loadSetMembers: (state, { payload: [match, resetData = true] }: PayloadAction<[string, Maybe]>) => { state.loading = true state.error = '' + + if (resetData) { + state.data = initialState.data + } + state.data = { - ...initialState.data, - match: payload || '*', + ...state.data, + match: match || '*' } }, loadSetMembersSuccess: ( @@ -150,10 +155,11 @@ export function fetchSetMembers( cursor: number, count: number, match: string, + resetData?: boolean, onSuccess?: (data: GetSetMembersResponse) => void, ) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { - dispatch(loadSetMembers(match)) + dispatch(loadSetMembers([match, resetData])) try { const state = stateInit() @@ -220,11 +226,11 @@ export function fetchMoreSetMembers( } // Asynchronous thunk actions -export function refreshSetMembersAction(key: string = '') { +export function refreshSetMembersAction(key: string = '', resetData?: boolean) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { const state = stateInit() const { match } = state.browser.set.data - dispatch(loadSetMembers(match || '*')) + dispatch(loadSetMembers([match || '*', resetData])) try { const { data, status } = await apiService.post( @@ -271,18 +277,6 @@ export function addSetMembersAction( ) if (isStatusSuccessful(status)) { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - state.browser.keys?.viewType, - TelemetryEvent.BROWSER_KEY_VALUE_ADDED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_ADDED - ), - eventData: { - databaseId: state.connections.instances?.connectedInstance?.id, - keyType: KeyTypes.Set, - numberOfAdded: data.members.length, - } - }) dispatch(addSetMembersSuccess()) dispatch(fetchKeyInfo(data.keyName)) onSuccessAction?.() @@ -297,7 +291,7 @@ export function addSetMembersAction( } // Asynchronous thunk actions -export function deleteSetMembers(key: string, members: string[]) { +export function deleteSetMembers(key: string, members: string[], onSuccessAction?: () => void,) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { dispatch(removeSetMembers()) @@ -317,18 +311,7 @@ export function deleteSetMembers(key: string, members: string[]) { ) if (isStatusSuccessful(status)) { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - state.browser.keys?.viewType, - TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED - ), - eventData: { - databaseId: state.connections.instances?.connectedInstance?.id, - keyType: KeyTypes.Set, - numberOfRemoved: members.length, - } - }) + onSuccessAction?.() const newTotalValue = state.browser.set.data.total - data.affected dispatch(removeSetMembersSuccess()) dispatch(removeMembersFromList(members)) diff --git a/redisinsight/ui/src/slices/browser/stream.ts b/redisinsight/ui/src/slices/browser/stream.ts new file mode 100644 index 0000000000..a4d84d64db --- /dev/null +++ b/redisinsight/ui/src/slices/browser/stream.ts @@ -0,0 +1,373 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import axios, { AxiosError, CancelTokenSource } from 'axios' + +import { apiService } from 'uiSrc/services' +import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' +import { ApiEndpoints, SortOrder } from 'uiSrc/constants' +import { refreshKeyInfoAction, } from 'uiSrc/slices/browser/keys' +import { getApiErrorMessage, getUrl, isStatusSuccessful, Maybe, Nullable } from 'uiSrc/utils' +import { getStreamRangeStart, getStreamRangeEnd } from 'uiSrc/utils/streamUtils' +import successMessages from 'uiSrc/components/notifications/success-messages' +import { + AddStreamEntriesDto, + AddStreamEntriesResponse, + GetStreamEntriesResponse, +} from 'apiSrc/modules/browser/dto/stream.dto' +import { AppDispatch, RootState } from '../store' +import { StateStream } from '../interfaces/stream' +import { addErrorNotification, addMessageNotification } from '../app/notifications' + +export const initialState: StateStream = { + loading: false, + error: '', + sortOrder: SortOrder.DESC, + range: { start: '', end: '' }, + data: { + total: 0, + entries: [], + keyName: '', + lastGeneratedId: '', + firstEntry: { + id: '', + fields: {} + }, + lastEntry: { + id: '', + fields: {} + }, + }, +} + +// A slice for recipes +const streamSlice = createSlice({ + name: 'stream', + initialState, + reducers: { + // load stream entries + loadEntries: (state, { payload: resetData = true }: PayloadAction>) => { + state.loading = true + state.error = '' + + if (resetData) { + state.data = initialState.data + } + }, + loadEntriesSuccess: (state, { payload: [data, sortOrder] }: + PayloadAction<[GetStreamEntriesResponse, SortOrder]>) => { + state.data = { + ...state.data, + ...data, + } + state.data.keyName = data?.keyName + state.sortOrder = sortOrder + state.loading = false + }, + loadEntriesFailure: (state, { payload }) => { + state.loading = false + state.error = payload + }, + // load more stream entries + loadMoreEntries: (state) => { + state.loading = true + state.error = '' + }, + loadMoreEntriesSuccess: (state, { payload: { entries, ...rest } }: PayloadAction) => { + state.data = { + ...state.data, + ...rest, + entries: state.data?.entries?.concat(entries), + } + state.loading = false + }, + loadMoreEntriesFailure: (state, { payload }) => { + state.loading = false + state.error = payload + }, + addNewEntries: (state) => { + state.loading = true + state.error = '' + }, + addNewEntriesSuccess: (state) => { + state.loading = false + }, + addNewEntriesFailure: (state, { payload }) => { + state.loading = false + state.error = payload + }, + // delete Stream entries + removeStreamEntries: (state) => { + state.loading = true + state.error = '' + }, + removeStreamEntriesSuccess: (state) => { + state.loading = false + }, + removeStreamEntriesFailure: (state, { payload }) => { + state.loading = false + state.error = payload + }, + updateStart: (state, { payload }: PayloadAction) => { + state.range.start = payload + }, + updateEnd: (state, { payload }: PayloadAction) => { + state.range.end = payload + }, + cleanRangeFilter: (state) => { + state.range = { + start: '', + end: '', + } + }, + }, +}) + +// Actions generated from the slice +export const { + loadEntries, + loadEntriesSuccess, + loadEntriesFailure, + loadMoreEntries, + loadMoreEntriesSuccess, + loadMoreEntriesFailure, + addNewEntries, + addNewEntriesSuccess, + addNewEntriesFailure, + removeStreamEntries, + removeStreamEntriesSuccess, + removeStreamEntriesFailure, + updateStart, + updateEnd, + cleanRangeFilter +} = streamSlice.actions + +// A selector +export const streamSelector = (state: RootState) => state.browser.stream +export const streamDataSelector = (state: RootState) => state.browser.stream?.data +export const streamRangeSelector = (state: RootState) => state.browser.stream?.range + +// The reducer +export default streamSlice.reducer + +// eslint-disable-next-line import/no-mutable-exports +export let sourceStreamFetch: Nullable = null + +// Asynchronous thunk action +export function fetchStreamEntries( + key: string, + count: number, + sortOrder: SortOrder, + resetData?: boolean, + onSuccess?: (data: GetStreamEntriesResponse) => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(loadEntries(resetData)) + + try { + sourceStreamFetch?.cancel?.() + + const { CancelToken } = axios + sourceStreamFetch = CancelToken.source() + + const state = stateInit() + const start = getStreamRangeStart(state.browser.stream.range.start, state.browser.stream.data.firstEntry?.id) + const end = getStreamRangeEnd(state.browser.stream.range.end, state.browser.stream.data.lastEntry?.id) + const { data, status } = await apiService.post( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.STREAMS_ENTRIES_GET + ), + { + keyName: key, + start, + end, + count, + sortOrder + }, + { cancelToken: sourceStreamFetch.token } + ) + + sourceStreamFetch = null + if (isStatusSuccessful(status)) { + dispatch(loadEntriesSuccess([data, sortOrder])) + onSuccess?.(data) + } + } catch (_err) { + if (!axios.isCancel(_err)) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(loadEntriesFailure(errorMessage)) + } + } + } +} + +// Asynchronous thunk action +export function refreshStreamEntries( + key: string, + resetData?: boolean, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(loadEntries(resetData)) + + try { + sourceStreamFetch?.cancel?.() + + const { CancelToken } = axios + sourceStreamFetch = CancelToken.source() + + const state = stateInit() + const { sortOrder } = state.browser.stream + const start = getStreamRangeStart(state.browser.stream.range.start, state.browser.stream.data.firstEntry?.id) + const end = getStreamRangeEnd(state.browser.stream.range.end, state.browser.stream.data.lastEntry?.id) + const { data, status } = await apiService.post( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.STREAMS_ENTRIES_GET + ), + { + keyName: key, + start, + end, + sortOrder, + count: SCAN_COUNT_DEFAULT, + }, + { cancelToken: sourceStreamFetch.token } + ) + + sourceStreamFetch = null + if (isStatusSuccessful(status)) { + dispatch(loadEntriesSuccess([data, sortOrder])) + } + } catch (_err) { + if (!axios.isCancel(_err)) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(loadEntriesFailure(errorMessage)) + } + } + } +} + +// Asynchronous thunk action +export function fetchMoreStreamEntries( + key: string, + start: string, + end: string, + count: number, + sortOrder: SortOrder, + onSuccess?: (data: GetStreamEntriesResponse) => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(loadMoreEntries()) + + try { + sourceStreamFetch?.cancel?.() + + const { CancelToken } = axios + sourceStreamFetch = CancelToken.source() + const state = stateInit() + const { data, status } = await apiService.post( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.STREAMS_ENTRIES_GET + ), + { + keyName: key, + start, + end, + count, + sortOrder + }, + { cancelToken: sourceStreamFetch.token } + ) + + sourceStreamFetch = null + if (isStatusSuccessful(status)) { + dispatch(loadMoreEntriesSuccess(data)) + onSuccess?.(data) + } + } catch (_err) { + if (!axios.isCancel(_err)) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(loadMoreEntriesFailure(errorMessage)) + } + } + } +} + +// Asynchronous thunk action +export function addNewEntriesAction( + data: AddStreamEntriesDto, + onSuccess?: () => void, + onFail?: () => void +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(addNewEntries()) + + try { + const state = stateInit() + const { status } = await apiService.post( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.STREAMS_ENTRIES + ), + data + ) + + if (isStatusSuccessful(status)) { + dispatch(addNewEntriesSuccess()) + dispatch(refreshStreamEntries(data.keyName, false)) + dispatch(refreshKeyInfoAction(data.keyName)) + onSuccess?.() + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(addNewEntriesFailure(errorMessage)) + onFail?.() + } + } +} +// Asynchronous thunk actions +export function deleteStreamEntry(key: string, entries: string[], onSuccessAction?: () => void,) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(removeStreamEntries()) + try { + const state = stateInit() + const { status } = await apiService.delete( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.STREAMS_ENTRIES + ), + { + data: { + keyName: key, + entries, + }, + } + ) + if (isStatusSuccessful(status)) { + onSuccessAction?.() + dispatch(removeStreamEntriesSuccess()) + dispatch(refreshStreamEntries(key, false)) + dispatch(refreshKeyInfoAction(key)) + dispatch(addMessageNotification( + successMessages.REMOVED_KEY_VALUE( + key, + entries.join(''), + 'Entry' + ) + )) + } + } catch (error) { + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(removeStreamEntriesFailure(errorMessage)) + } + } +} diff --git a/redisinsight/ui/src/slices/string.ts b/redisinsight/ui/src/slices/browser/string.ts similarity index 86% rename from redisinsight/ui/src/slices/string.ts rename to redisinsight/ui/src/slices/browser/string.ts index 29e103d041..b588a2fe0a 100644 --- a/redisinsight/ui/src/slices/string.ts +++ b/redisinsight/ui/src/slices/browser/string.ts @@ -1,14 +1,13 @@ -import { createSlice } from '@reduxjs/toolkit' -import { cloneDeep } from 'lodash' +import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { ApiEndpoints, KeyTypes } from 'uiSrc/constants' import { apiService } from 'uiSrc/services' -import { getApiErrorMessage, getUrl, isStatusSuccessful } from 'uiSrc/utils' +import { getApiErrorMessage, getUrl, isStatusSuccessful, Maybe } from 'uiSrc/utils' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { refreshKeyInfoAction } from './keys' -import { addErrorNotification } from './app/notifications' -import { AppDispatch, RootState } from './store' -import { StringState } from './interfaces/string' +import { addErrorNotification } from '../app/notifications' +import { AppDispatch, RootState } from '../store' +import { StringState } from '../interfaces/string' export const initialState: StringState = { loading: false, @@ -25,10 +24,13 @@ const stringSlice = createSlice({ initialState, reducers: { // load String value - getString: (state) => { + getString: (state, { payload: resetData = true }: PayloadAction>) => { state.loading = true state.error = '' - state.data = cloneDeep(initialState.data) + + if (resetData) { + state.data = initialState.data + } }, getStringSuccess: (state, { payload }) => { state.data.key = payload.keyName @@ -78,9 +80,9 @@ export const stringDataSelector = (state: RootState) => export default stringSlice.reducer // Asynchronous thunk action -export function fetchString(key: string) { +export function fetchString(key: string, resetData?: boolean) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { - dispatch(getString()) + dispatch(getString(resetData)) try { const state = stateInit() diff --git a/redisinsight/ui/src/slices/zset.ts b/redisinsight/ui/src/slices/browser/zset.ts similarity index 93% rename from redisinsight/ui/src/slices/zset.ts rename to redisinsight/ui/src/slices/browser/zset.ts index 2f86ddfa1a..0b263d5b81 100644 --- a/redisinsight/ui/src/slices/zset.ts +++ b/redisinsight/ui/src/slices/browser/zset.ts @@ -1,9 +1,9 @@ import { cloneDeep, isNull, remove } from 'lodash' -import { createSlice } from '@reduxjs/toolkit' +import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { apiService } from 'uiSrc/services' import { ApiEndpoints, SortOrder, KeyTypes } from 'uiSrc/constants' -import { getApiErrorMessage, getUrl, isStatusSuccessful } from 'uiSrc/utils' +import { getApiErrorMessage, getUrl, isStatusSuccessful, Maybe } from 'uiSrc/utils' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { StateZset } from 'uiSrc/slices/interfaces/zset' import successMessages from 'uiSrc/components/notifications/success-messages' @@ -20,8 +20,8 @@ import { refreshKeyInfoAction, updateSelectedKeyRefreshTime, } from './keys' -import { AppDispatch, RootState } from './store' -import { addErrorNotification, addMessageNotification } from './app/notifications' +import { AppDispatch, RootState } from '../store' +import { addErrorNotification, addMessageNotification } from '../app/notifications' export const initialState: StateZset = { loading: false, @@ -49,13 +49,17 @@ const zsetSlice = createSlice({ reducers: { setZsetInitialState: () => initialState, // load ZSet members - loadZSetMembers: (state, { payload }) => { + loadZSetMembers: (state, { payload: [sortOrder, resetData = true] }:PayloadAction<[SortOrder, Maybe]>) => { state.loading = true state.searching = false state.error = '' + + if (resetData) { + state.data = initialState.data + } state.data = { - ...initialState.data, - sortOrder: payload, + ...state.data, + sortOrder, } }, loadZSetMembersSuccess: (state, { payload }) => { @@ -217,10 +221,11 @@ export function fetchZSetMembers( key: string, offset: number, count: number, - sortOrder: SortOrder + sortOrder: SortOrder, + resetData?: boolean ) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { - dispatch(loadZSetMembers(sortOrder)) + dispatch(loadZSetMembers([sortOrder, resetData])) try { const state = stateInit() @@ -327,7 +332,7 @@ export function fetchAddZSetMembers( } } -export function deleteZSetMembers(key: string, members: string[]) { +export function deleteZSetMembers(key: string, members: string[], onSuccessAction?: () => void,) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { dispatch(removeZsetMembers()) try { @@ -345,18 +350,7 @@ export function deleteZSetMembers(key: string, members: string[]) { } ) if (isStatusSuccessful(status)) { - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - state.browser.keys?.viewType, - TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED - ), - eventData: { - databaseId: state.connections.instances?.connectedInstance?.id, - keyType: KeyTypes.ZSet, - numberOfRemoved: members.length, - } - }) + onSuccessAction?.() const newTotalValue = state.browser.zset.data.total - data.affected dispatch(removeZsetMembersSuccess()) dispatch(removeMembersFromList(members)) @@ -494,7 +488,7 @@ export function fetchSearchMoreZSetMembers( } } -export function refreshZsetMembersAction(key: string = '') { +export function refreshZsetMembersAction(key: string = '', resetData?: boolean) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { const state = stateInit() const { searching } = state.browser.zset @@ -530,7 +524,7 @@ export function refreshZsetMembersAction(key: string = '') { return } const { sortOrder } = state.browser.zset.data - dispatch(loadZSetMembers(sortOrder)) + dispatch(loadZSetMembers([sortOrder, resetData])) try { const state = stateInit() diff --git a/redisinsight/ui/src/slices/cli/cli-settings.ts b/redisinsight/ui/src/slices/cli/cli-settings.ts index ed58cb1149..9771ed44fe 100644 --- a/redisinsight/ui/src/slices/cli/cli-settings.ts +++ b/redisinsight/ui/src/slices/cli/cli-settings.ts @@ -6,7 +6,7 @@ import { ApiEndpoints, BrowserStorageItem } from 'uiSrc/constants' import { getApiErrorMessage, getUrl, isStatusSuccessful } from 'uiSrc/utils' import { concatToOutput, setCliDbIndex } from 'uiSrc/slices/cli/cli-output' import { cliTexts, ConnectionSuccessOutputText, InitOutputText } from 'uiSrc/constants/cliOutput' -import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { AppDispatch, RootState } from '../store' import { StateCliSettings } from '../interfaces/cli' @@ -35,6 +35,10 @@ const cliSettingsSlice = createSlice({ reducers: { setCliSettingsInitialState: () => initialState, // collapse / uncollapse CLI + openCli: (state) => { + state.isShowCli = true + }, + toggleCli: (state) => { state.isShowCli = !state.isShowCli }, @@ -140,6 +144,7 @@ const cliSettingsSlice = createSlice({ // Actions generated from the slice export const { setCliSettingsInitialState, + openCli, toggleCli, toggleCliHelper, toggleHideCliHelper, diff --git a/redisinsight/ui/src/slices/caCerts.ts b/redisinsight/ui/src/slices/instances/caCerts.ts similarity index 93% rename from redisinsight/ui/src/slices/caCerts.ts rename to redisinsight/ui/src/slices/instances/caCerts.ts index 7716364eba..2b69d8fd88 100644 --- a/redisinsight/ui/src/slices/caCerts.ts +++ b/redisinsight/ui/src/slices/instances/caCerts.ts @@ -3,8 +3,8 @@ import { createSlice } from '@reduxjs/toolkit' import { ApiEndpoints } from 'uiSrc/constants' import { apiService } from 'uiSrc/services' import { getApiErrorMessage, isStatusSuccessful } from 'uiSrc/utils' -import { addErrorNotification } from './app/notifications' -import { AppDispatch, RootState } from './store' +import { addErrorNotification } from '../app/notifications' +import { AppDispatch, RootState } from '../store' export const initialState = { loading: false, diff --git a/redisinsight/ui/src/slices/clientCerts.ts b/redisinsight/ui/src/slices/instances/clientCerts.ts similarity index 93% rename from redisinsight/ui/src/slices/clientCerts.ts rename to redisinsight/ui/src/slices/instances/clientCerts.ts index 45a7ac5778..35523458fb 100644 --- a/redisinsight/ui/src/slices/clientCerts.ts +++ b/redisinsight/ui/src/slices/instances/clientCerts.ts @@ -3,8 +3,8 @@ import { createSlice } from '@reduxjs/toolkit' import { ApiEndpoints } from 'uiSrc/constants' import { apiService } from 'uiSrc/services' import { getApiErrorMessage, isStatusSuccessful } from 'uiSrc/utils' -import { addErrorNotification } from './app/notifications' -import { AppDispatch, RootState } from './store' +import { addErrorNotification } from '../app/notifications' +import { AppDispatch, RootState } from '../store' export const initialState = { loading: false, diff --git a/redisinsight/ui/src/slices/cloud.ts b/redisinsight/ui/src/slices/instances/cloud.ts similarity index 98% rename from redisinsight/ui/src/slices/cloud.ts rename to redisinsight/ui/src/slices/instances/cloud.ts index bb1bc3a6e8..12ba2e8c94 100644 --- a/redisinsight/ui/src/slices/cloud.ts +++ b/redisinsight/ui/src/slices/instances/cloud.ts @@ -16,9 +16,9 @@ import { InitialStateCloud, InstanceRedisCloud, LoadedCloud, -} from './interfaces' -import { addErrorNotification } from './app/notifications' -import { AppDispatch, RootState } from './store' +} from '../interfaces' +import { addErrorNotification } from '../app/notifications' +import { AppDispatch, RootState } from '../store' export const initialState: InitialStateCloud = { loading: false, diff --git a/redisinsight/ui/src/slices/cluster.ts b/redisinsight/ui/src/slices/instances/cluster.ts similarity index 96% rename from redisinsight/ui/src/slices/cluster.ts rename to redisinsight/ui/src/slices/instances/cluster.ts index 60b0f053c7..899d648b80 100644 --- a/redisinsight/ui/src/slices/cluster.ts +++ b/redisinsight/ui/src/slices/instances/cluster.ts @@ -13,9 +13,9 @@ import { ICredentialsRedisCluster, InitialStateCluster, InstanceRedisCluster, -} from './interfaces' -import { addErrorNotification } from './app/notifications' -import { AppDispatch, RootState } from './store' +} from '../interfaces' +import { addErrorNotification } from '../app/notifications' +import { AppDispatch, RootState } from '../store' export const initialState: InitialStateCluster = { loading: false, diff --git a/redisinsight/ui/src/slices/instances.ts b/redisinsight/ui/src/slices/instances/instances.ts similarity index 98% rename from redisinsight/ui/src/slices/instances.ts rename to redisinsight/ui/src/slices/instances/instances.ts index 3fd7bb62cd..d61dc5febe 100644 --- a/redisinsight/ui/src/slices/instances.ts +++ b/redisinsight/ui/src/slices/instances/instances.ts @@ -9,11 +9,11 @@ import { setAppContextInitialState } from 'uiSrc/slices/app/context' import successMessages from 'uiSrc/components/notifications/success-messages' import { checkRediStack, getApiErrorMessage, isStatusSuccessful, Nullable } from 'uiSrc/utils' import { DatabaseInstanceResponse } from 'apiSrc/modules/instances/dto/database-instance.dto' - -import { AppDispatch, RootState } from './store' import { fetchMastersSentinelAction } from './sentinel' -import { addErrorNotification, addMessageNotification } from './app/notifications' -import { Instance, InitialStateInstances, ConnectionType } from './interfaces' + +import { AppDispatch, RootState } from '../store' +import { addErrorNotification, addMessageNotification } from '../app/notifications' +import { Instance, InitialStateInstances, ConnectionType } from '../interfaces' export const initialState: InitialStateInstances = { loading: false, diff --git a/redisinsight/ui/src/slices/sentinel.ts b/redisinsight/ui/src/slices/instances/sentinel.ts similarity index 97% rename from redisinsight/ui/src/slices/sentinel.ts rename to redisinsight/ui/src/slices/instances/sentinel.ts index afaf0685d2..e4a9eadaa3 100644 --- a/redisinsight/ui/src/slices/sentinel.ts +++ b/redisinsight/ui/src/slices/instances/sentinel.ts @@ -19,9 +19,9 @@ import { Instance, LoadedSentinel, ModifiedSentinelMaster, -} from './interfaces' -import { AppDispatch, RootState } from './store' -import { addErrorNotification } from './app/notifications' +} from '../interfaces' +import { AppDispatch, RootState } from '../store' +import { addErrorNotification } from '../app/notifications' export const initialState: InitialStateSentinel = { loading: false, diff --git a/redisinsight/ui/src/slices/interfaces/instances.ts b/redisinsight/ui/src/slices/interfaces/instances.ts index 21c2967747..1cab8a59ef 100644 --- a/redisinsight/ui/src/slices/interfaces/instances.ts +++ b/redisinsight/ui/src/slices/interfaces/instances.ts @@ -1,7 +1,7 @@ -import { Nullable } from 'uiSrc/utils' +import { Maybe, Nullable } from 'uiSrc/utils' import { GetHashFieldsResponse } from 'apiSrc/modules/browser/dto/hash.dto' import { GetSetMembersResponse } from 'apiSrc/modules/browser/dto/set.dto' -import { GetRejsonRlResponseDto } from 'apiSrc/modules/browser/dto/rejson-rl.dto' +import { GetRejsonRlResponseDto, SafeRejsonRlDataDtO } from 'apiSrc/modules/browser/dto/rejson-rl.dto' import { GetListElementsDto, GetListElementsResponse, @@ -15,28 +15,28 @@ import { SearchZSetMembersResponse } from 'apiSrc/modules/browser/dto' import { AddSentinelMasterDto, AddSentinelMasterResponse } from 'apiSrc/modules/instances/dto/redis-sentinel.dto' export interface Instance extends DatabaseInstanceResponse { - host: string; - port: number; - nameFromProvider?: Nullable; - provider?: string; - id: string; - endpoints?: Nullable; - connectionType?: ConnectionType; - lastConnection?: Date; - password?: Nullable; - username?: Nullable; - name?: string; - tls?: TlsSettings; - tlsClientAuthRequired?: boolean; - tlsClientCertId?: number | undefined; - verifyServerCert?: boolean; - caCertName?: string; - authUsername?: Nullable; - authPass?: Nullable; - isDeleting?: boolean; - sentinelMaster?: SentinelMasterDto; - modules: RedisModuleDto[]; - isRediStack?: boolean; + host: string + port: number + nameFromProvider?: Nullable + provider?: string + id: string + endpoints?: Nullable + connectionType?: ConnectionType + lastConnection?: Date + password?: Nullable + username?: Nullable + name?: string + tls?: TlsSettings + tlsClientAuthRequired?: boolean + tlsClientCertId?: number | undefined + verifyServerCert?: boolean + caCertName?: string + authUsername?: Nullable + authPass?: Nullable + isDeleting?: boolean + sentinelMaster?: SentinelMasterDto + modules: RedisModuleDto[] + isRediStack?: boolean } export enum ConnectionType { @@ -45,6 +45,16 @@ export enum ConnectionType { Sentinel = 'SENTINEL', } +export enum ConnectionProvider { + UNKNOWN = 'UNKNOWN', + LOCALHOST = 'LOCALHOST', + RE_CLUSTER = 'RE_CLUSTER', + RE_CLOUD = 'RE_CLOUD', + AZURE = 'AZURE', + AWS = 'AWS', + GOOGLE = 'GOOGLE', +} + export const CONNECTION_TYPE_DISPLAY = Object.freeze({ [ConnectionType.Standalone]: 'Standalone', [ConnectionType.Cluster]: 'OSS Cluster', @@ -52,55 +62,55 @@ export const CONNECTION_TYPE_DISPLAY = Object.freeze({ }) export interface Endpoints { - host: string; - port: number; + host: string + port: number } export interface InstanceRedisCluster { - host: string; - port: number; - uid: number; - name: string; - id?: number; - dnsName: string; - address: string; - status: InstanceRedisClusterStatus; - modules: RedisDefaultModules[]; - tls: boolean; - options: any; - message?: string; - uidAdded?: number; - statusAdded?: AddRedisDatabaseStatus; - messageAdded?: string; - databaseDetails?: InstanceRedisCluster; + host: string + port: number + uid: number + name: string + id?: number + dnsName: string + address: string + status: InstanceRedisClusterStatus + modules: RedisDefaultModules[] + tls: boolean + options: any + message?: string + uidAdded?: number + statusAdded?: AddRedisDatabaseStatus + messageAdded?: string + databaseDetails?: InstanceRedisCluster } export interface InstanceRedisCloud { - accessKey: string; - secretKey: string; - credentials: Nullable; - account: Nullable; - host: string; - port: number; + accessKey: string + secretKey: string + credentials: Nullable + account: Nullable + host: string + port: number uid: number; - name: string; - id?: number; - dnsName: string; - address: string; - status: InstanceRedisClusterStatus; - modules: RedisDefaultModules[]; - tls: boolean; - options: any; - message?: string; - publicEndpoint?: string; - databaseId: number; - databaseIdAdded?: number; - subscriptionId?: number; - subscriptionName: string; - subscriptionIdAdded?: number; - statusAdded?: AddRedisDatabaseStatus; - messageAdded?: string; - databaseDetails?: InstanceRedisCluster; + name: string + id?: number + dnsName: string + address: string + status: InstanceRedisClusterStatus + modules: RedisDefaultModules[] + tls: boolean + options: any + message?: string + publicEndpoint?: string + databaseId: number + databaseIdAdded?: number + subscriptionId?: number + subscriptionName: string + subscriptionIdAdded?: number + statusAdded?: AddRedisDatabaseStatus + messageAdded?: string + databaseDetails?: InstanceRedisCluster } export interface IBulkOperationResult { @@ -185,16 +195,16 @@ export enum InstanceRedisClusterStatus { } export interface TlsSettings { - caCertId?: string; - clientCertPairId?: string; - verifyServerCert?: boolean; + caCertId?: string + clientCertPairId?: string + verifyServerCert?: boolean } export interface ClusterNode { - host: string; - port: number; - role?: 'slave' | 'master'; - slot?: number; + host: string + port: number + role?: 'slave' | 'master' + slot?: number } export enum RedisCloudSubscriptionStatus { @@ -214,66 +224,66 @@ export const RedisCloudSubscriptionStatusText = Object.freeze({ }) export interface RedisCloudSubscription { - id: number; - name: string; - numberOfDatabases: number; - provider: string; - region: string; - status: RedisCloudSubscriptionStatus; + id: number + name: string + numberOfDatabases: number + provider: string + region: string + status: RedisCloudSubscriptionStatus } export interface DatabaseConfigInfo { - version: string; - totalKeys?: Nullable; - usedMemory?: Nullable; - connectedClients?: Nullable; - opsPerSecond?: Nullable; - networkInKbps?: Nullable; - networkOutKbps?: Nullable; - cpuUsagePercentage?: Nullable; + version: string + totalKeys?: Nullable + usedMemory?: Nullable + connectedClients?: Nullable + opsPerSecond?: Nullable + networkInKbps?: Nullable + networkOutKbps?: Nullable + cpuUsagePercentage?: Nullable } export interface InitialStateInstances { - loading: boolean; - error: string; - data: Instance[]; - loadingChanging: boolean; - errorChanging: string; - changedSuccessfully: boolean; - deletedSuccessfully: boolean; - connectedInstance: Instance; - instanceOverview: DatabaseConfigInfo; + loading: boolean + error: string + data: Instance[] + loadingChanging: boolean + errorChanging: string + changedSuccessfully: boolean + deletedSuccessfully: boolean + connectedInstance: Instance + instanceOverview: DatabaseConfigInfo } export interface InitialStateCluster { - loading: boolean; - data: Nullable; - dataAdded: InstanceRedisCluster[]; - error: string; - credentials: Nullable; + loading: boolean + data: Nullable + dataAdded: InstanceRedisCluster[] + error: string + credentials: Nullable } export interface InitialStateCloud { - loading: boolean; - data: Nullable; - dataAdded: InstanceRedisCloud[]; - error: string; - credentials: Nullable; - subscriptions: Nullable; + loading: boolean + data: Nullable + dataAdded: InstanceRedisCloud[] + error: string + credentials: Nullable + subscriptions: Nullable account: { - data: Nullable; - error: string; - }; - loaded: ILoadedCloud; + data: Nullable + error: string + } + loaded: ILoadedCloud } export interface InitialStateSentinel { - loading: boolean; - instance: Nullable; - data: ModifiedSentinelMaster[]; - statuses: AddSentinelMasterResponse[]; - error: string; - loaded: ILoadedSentinel; + loading: boolean + instance: Nullable + data: ModifiedSentinelMaster[] + statuses: AddSentinelMasterResponse[] + error: string + loaded: ILoadedSentinel } export enum LoadedCloud { @@ -288,42 +298,42 @@ export enum LoadedSentinel { } export interface ILoadedCloud { - [LoadedCloud.Subscriptions]?: boolean; - [LoadedCloud.Instances]?: boolean; - [LoadedCloud.InstancesAdded]?: boolean; + [LoadedCloud.Subscriptions]?: boolean + [LoadedCloud.Instances]?: boolean + [LoadedCloud.InstancesAdded]?: boolean } export interface ILoadedSentinel { - [LoadedSentinel.Masters]?: boolean; - [LoadedSentinel.MastersAdded]?: boolean; + [LoadedSentinel.Masters]?: boolean + [LoadedSentinel.MastersAdded]?: boolean } export interface ModifiedGetSetMembersResponse extends GetSetMembersResponse { - key?: string; - match?: string; + key?: string + match?: string } export interface ModifiedZsetMembersResponse extends SearchZSetMembersResponse { - key?: string; - match?: string; + key?: string + match?: string } export interface ModifiedGetHashMembersResponse extends GetHashFieldsResponse { - key?: string; - match?: string; + key?: string + match?: string } export interface ModifiedSentinelMaster extends AddSentinelMasterDto { - id?: string; - alias?: string; - host?: string; - port?: string; - username?: string; - password?: string; - loading?: boolean; - message?: string; - status?: AddRedisDatabaseStatus; - error?: string | object; + id?: string + alias?: string + host?: string + port?: string + username?: string + password?: string + loading?: boolean + message?: string + status?: AddRedisDatabaseStatus + error?: string | object } export interface ModifiedGetListElementsResponse @@ -339,29 +349,33 @@ export interface InitialStateSet { data: ModifiedGetSetMembersResponse; } +export interface GetRejsonRlResponse extends GetRejsonRlResponseDto { + data: Maybe +} + export interface InitialStateRejson { - loading: boolean; - error: string; - data: GetRejsonRlResponseDto; + loading: boolean + error: string + data: GetRejsonRlResponse } export interface ICredentialsRedisCluster { - host: string; - port: number; - username: string; - password: string; + host: string + port: number + username: string + password: string } export interface RedisCloudAccount { - accountId: Nullable; - accountName: Nullable; - ownerEmail: Nullable; - ownerName: Nullable; + accountId: Nullable + accountName: Nullable + ownerEmail: Nullable + ownerName: Nullable } export interface ICredentialsRedisCloud { - accessKey: Nullable; - secretKey: Nullable; + accessKey: Nullable + secretKey: Nullable } export enum InstanceType { diff --git a/redisinsight/ui/src/slices/interfaces/keys.ts b/redisinsight/ui/src/slices/interfaces/keys.ts index f3c4d808ab..f92477fc61 100644 --- a/redisinsight/ui/src/slices/interfaces/keys.ts +++ b/redisinsight/ui/src/slices/interfaces/keys.ts @@ -1,5 +1,5 @@ import { GetKeyInfoResponse } from 'apiSrc/modules/browser/dto' -import { KeyTypes, UnsupportedKeyTypes } from 'uiSrc/constants' +import { KeyTypes } from 'uiSrc/constants' import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' import { Maybe, Nullable } from 'uiSrc/utils' @@ -8,6 +8,7 @@ export interface Key { type: KeyTypes ttl: number size: number + length: number } export enum KeyViewType { @@ -19,10 +20,11 @@ export interface KeysStore { loading: boolean error: string search: string - filter: Nullable + filter: Nullable isFiltered: boolean isSearched: boolean - viewType: KeyViewType, + isBrowserFullScreen: boolean + viewType: KeyViewType data: KeysStoreData selectedKey: { loading: boolean diff --git a/redisinsight/ui/src/slices/interfaces/slowlog.ts b/redisinsight/ui/src/slices/interfaces/slowlog.ts new file mode 100644 index 0000000000..bde1f4ee50 --- /dev/null +++ b/redisinsight/ui/src/slices/interfaces/slowlog.ts @@ -0,0 +1,12 @@ +import { SlowLog, SlowLogConfig } from 'apiSrc/modules/slow-log/models' +import { DurationUnits } from 'uiSrc/constants' +import { Nullable } from 'uiSrc/utils' + +export interface StateSlowLog { + loading: boolean + error: string + data: SlowLog[] + lastRefreshTime: Nullable, + config: Nullable, + durationUnit: DurationUnits +} diff --git a/redisinsight/ui/src/slices/interfaces/stream.ts b/redisinsight/ui/src/slices/interfaces/stream.ts new file mode 100644 index 0000000000..3d1935fb4d --- /dev/null +++ b/redisinsight/ui/src/slices/interfaces/stream.ts @@ -0,0 +1,15 @@ +import { GetStreamEntriesResponse } from 'apiSrc/modules/browser/dto/stream.dto' +import { SortOrder } from 'uiSrc/constants' + +type Range = { + start: string, + end: string, +} + +export interface StateStream { + loading: boolean + error: string + sortOrder: SortOrder + range: Range, + data: GetStreamEntriesResponse +} diff --git a/redisinsight/ui/src/slices/slowlog/slowlog.ts b/redisinsight/ui/src/slices/slowlog/slowlog.ts new file mode 100644 index 0000000000..9d78d53e98 --- /dev/null +++ b/redisinsight/ui/src/slices/slowlog/slowlog.ts @@ -0,0 +1,233 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { AxiosError } from 'axios' +import { ApiEndpoints, DEFAULT_SLOWLOG_DURATION_UNIT, DurationUnits } from 'uiSrc/constants' +import { apiService, getDBConfigStorageField } from 'uiSrc/services' +import { addErrorNotification } from 'uiSrc/slices/app/notifications' +import { StateSlowLog } from 'uiSrc/slices/interfaces/slowlog' +import { ConfigDBStorageItem } from 'uiSrc/constants/storage' +import { getApiErrorMessage, getUrl, isStatusSuccessful, Nullable } from 'uiSrc/utils' +import { SlowLog, SlowLogConfig } from 'apiSrc/modules/slow-log/models' + +import { AppDispatch, RootState } from '../store' + +export const initialState: StateSlowLog = { + loading: false, + error: '', + data: [], + lastRefreshTime: null, + durationUnit: DurationUnits.microSeconds, + config: null +} + +const slowLogSlice = createSlice({ + name: 'slowlog', + initialState, + reducers: { + setSlowLogInitialState: () => initialState, + getSlowLogs: (state) => { + state.loading = true + }, + getSlowLogsSuccess: ( + state, + { payload: [data, durationUnit] }: PayloadAction<[SlowLog[], DurationUnits]> + ) => { + state.loading = false + state.data = data + state.durationUnit = durationUnit + state.lastRefreshTime = Date.now() + }, + getSlowLogsError: (state, { payload }) => { + state.loading = false + state.error = payload + }, + deleteSlowLogs: (state) => { + state.loading = true + }, + deleteSlowLogsSuccess: (state) => { + state.loading = false + state.data = [] + }, + deleteSlowLogsError: (state, { payload }) => { + state.loading = false + state.error = payload + }, + getSlowLogConfig: (state) => { + state.loading = true + }, + getSlowLogConfigSuccess: ( + state, + { payload: [data, durationUnit] }: PayloadAction<[SlowLogConfig, Nullable ]> + ) => { + state.loading = false + state.config = data + + if (durationUnit) { + state.durationUnit = durationUnit + } + }, + getSlowLogConfigError: (state, { payload }) => { + state.loading = false + state.error = payload + }, + } +}) + +export const slowLogSelector = (state: RootState) => state.slowlog +export const slowLogConfigSelector = (state: RootState) => state.slowlog.config || {} + +export const { + setSlowLogInitialState, + getSlowLogs, + getSlowLogsSuccess, + getSlowLogsError, + deleteSlowLogs, + deleteSlowLogsSuccess, + deleteSlowLogsError, + getSlowLogConfig, + getSlowLogConfigSuccess, + getSlowLogConfigError +} = slowLogSlice.actions + +// The reducer +export default slowLogSlice.reducer + +// Asynchronous thunk action +export function fetchSlowLogsAction( + instanceId: string, + count: number, + onSuccessAction?: (data: SlowLog[]) => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch) => { + try { + dispatch(getSlowLogs()) + + const { data, status } = await apiService.get( + getUrl( + instanceId, + ApiEndpoints.SLOW_LOGS + ), + { + params: { count } + } + ) + + if (isStatusSuccessful(status)) { + dispatch( + getSlowLogsSuccess([ + data, + getDBConfigStorageField(instanceId, ConfigDBStorageItem.slowLogDurationUnit) + || DEFAULT_SLOWLOG_DURATION_UNIT + ]) + ) + + onSuccessAction?.(data) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(getSlowLogsError(errorMessage)) + onFailAction?.() + } + } +} + +// Asynchronous thunk action +export function clearSlowLogAction( + instanceId: string, + onSuccessAction?: () => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch) => { + try { + dispatch(deleteSlowLogs()) + + const { status } = await apiService.delete( + getUrl( + instanceId, + ApiEndpoints.SLOW_LOGS + ), + ) + + if (isStatusSuccessful(status)) { + dispatch(deleteSlowLogsSuccess()) + + onSuccessAction?.() + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(deleteSlowLogsError(errorMessage)) + onFailAction?.() + } + } +} + +// Asynchronous thunk action +export function getSlowLogConfigAction( + instanceId: string, + onSuccessAction?: () => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch) => { + try { + dispatch(getSlowLogConfig()) + + const { data, status } = await apiService.get( + getUrl( + instanceId, + ApiEndpoints.SLOW_LOGS_CONFIG + ), + ) + + if (isStatusSuccessful(status)) { + dispatch(getSlowLogConfigSuccess([data, null])) + + onSuccessAction?.() + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(getSlowLogConfigError(errorMessage)) + onFailAction?.() + } + } +} + +// Asynchronous thunk action +export function patchSlowLogConfigAction( + instanceId: string, + config: SlowLogConfig, + durationUnit: DurationUnits, + onSuccessAction?: () => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch) => { + try { + dispatch(getSlowLogConfig()) + + const { data, status } = await apiService.patch( + getUrl( + instanceId, + ApiEndpoints.SLOW_LOGS_CONFIG + ), + config + ) + + if (isStatusSuccessful(status)) { + dispatch(getSlowLogConfigSuccess([data, durationUnit])) + + onSuccessAction?.() + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(getSlowLogConfigError(errorMessage)) + onFailAction?.() + } + } +} diff --git a/redisinsight/ui/src/slices/store.ts b/redisinsight/ui/src/slices/store.ts index e05f4cfdb5..31f8e8ee6e 100644 --- a/redisinsight/ui/src/slices/store.ts +++ b/redisinsight/ui/src/slices/store.ts @@ -1,19 +1,20 @@ import { createBrowserHistory } from 'history' import { configureStore, combineReducers } from '@reduxjs/toolkit' -import instancesReducer from './instances' -import caCertsReducer from './caCerts' -import clientCertsReducer from './clientCerts' -import clusterReducer from './cluster' -import cloudReducer from './cloud' -import sentinelReducer from './sentinel' -import keysReducer from './keys' -import stringReducer from './string' -import zsetReducer from './zset' -import setReducer from './set' -import hashReducer from './hash' -import listReducer from './list' -import rejsonReducer from './rejson' +import instancesReducer from './instances/instances' +import caCertsReducer from './instances/caCerts' +import clientCertsReducer from './instances/clientCerts' +import clusterReducer from './instances/cluster' +import cloudReducer from './instances/cloud' +import sentinelReducer from './instances/sentinel' +import keysReducer from './browser/keys' +import stringReducer from './browser/string' +import zsetReducer from './browser/zset' +import setReducer from './browser/set' +import hashReducer from './browser/hash' +import listReducer from './browser/list' +import rejsonReducer from './browser/rejson' +import streamReducer from './browser/stream' import notificationsReducer from './app/notifications' import cliSettingsReducer from './cli/cli-settings' import outputReducer from './cli/cli-output' @@ -27,6 +28,7 @@ import workbenchResultsReducer from './workbench/wb-results' import workbenchGuidesReducer from './workbench/wb-guides' import workbenchTutorialsReducer from './workbench/wb-tutorials' import contentCreateRedisButtonReducer from './content/create-redis-buttons' +import slowLogReducer from './slowlog/slowlog' export const history = createBrowserHistory() @@ -54,6 +56,7 @@ export const rootReducer = combineReducers({ hash: hashReducer, list: listReducer, rejson: rejsonReducer, + stream: streamReducer, }), cli: combineReducers({ settings: cliSettingsReducer, @@ -71,6 +74,7 @@ export const rootReducer = combineReducers({ content: combineReducers({ createRedisButtons: contentCreateRedisButtonReducer, }), + slowlog: slowLogReducer }) const store = configureStore({ diff --git a/redisinsight/ui/src/slices/tests/hash.spec.ts b/redisinsight/ui/src/slices/tests/browser/hash.spec.ts similarity index 98% rename from redisinsight/ui/src/slices/tests/hash.spec.ts rename to redisinsight/ui/src/slices/tests/browser/hash.spec.ts index 10bce81689..2acff3418a 100644 --- a/redisinsight/ui/src/slices/tests/hash.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/hash.spec.ts @@ -14,7 +14,7 @@ import { deleteKeySuccess, deleteKeyFromList, updateSelectedKeyRefreshTime, -} from '../keys' +} from '../../browser/keys' import reducer, { deleteHashFields, fetchMoreHashFields, @@ -39,8 +39,8 @@ import reducer, { updateFieldsInList, updateHashFieldsAction, refreshHashFieldsAction, -} from '../hash' -import { addErrorNotification, addMessageNotification } from '../app/notifications' +} from '../../browser/hash' +import { addErrorNotification, addMessageNotification } from '../../app/notifications' jest.mock('uiSrc/services') @@ -83,7 +83,7 @@ describe('hash slice', () => { } // Act - const nextState = reducer(initialState, loadHashFields('*')) + const nextState = reducer(initialState, loadHashFields(['*', undefined])) // Assert const rootState = Object.assign(initialStateDefault, { @@ -489,7 +489,7 @@ describe('hash slice', () => { // Assert const expectedActions = [ - loadHashFields('*'), + loadHashFields(['*', true]), loadHashFieldsSuccess(responsePayload.data), updateSelectedKeyRefreshTime(Date.now()), ] @@ -548,7 +548,7 @@ describe('hash slice', () => { // Assert const expectedActions = [ - loadHashFields('*'), + loadHashFields(['*', undefined]), loadHashFieldsSuccess(responsePayload.data), ] @@ -581,6 +581,7 @@ describe('hash slice', () => { const mockedStore = mockStore(nextState) apiService.delete = jest.fn().mockResolvedValue(responsePayload) + apiService.post = jest.fn().mockResolvedValue(responsePayload) // Act await mockedStore.dispatch(deleteHashFields(key, fields)) diff --git a/redisinsight/ui/src/slices/tests/keys.spec.ts b/redisinsight/ui/src/slices/tests/browser/keys.spec.ts similarity index 99% rename from redisinsight/ui/src/slices/tests/keys.spec.ts rename to redisinsight/ui/src/slices/tests/browser/keys.spec.ts index 71d6720f16..298948ba4a 100644 --- a/redisinsight/ui/src/slices/tests/keys.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/keys.spec.ts @@ -55,8 +55,8 @@ import reducer, { addStringKey, addZsetKey, updateSelectedKeyRefreshTime, -} from '../keys' -import { getString } from '../string' +} from '../../browser/keys' +import { getString } from '../../browser/string' jest.mock('uiSrc/services') @@ -1015,7 +1015,6 @@ describe('keys slice', () => { const expectedActions = [ addKey(), addKeySuccess(), - defaultSelectedKeyAction(), addMessageNotification(successMessages.ADDED_NEW_KEY(data.keyName)), ] expect(store.getActions()).toEqual(expectedActions) @@ -1040,7 +1039,6 @@ describe('keys slice', () => { const expectedActions = [ addKey(), addKeySuccess(), - defaultSelectedKeyAction(), addMessageNotification(successMessages.ADDED_NEW_KEY(data.keyName)), ] expect(store.getActions()).toEqual(expectedActions) @@ -1065,7 +1063,6 @@ describe('keys slice', () => { const expectedActions = [ addKey(), addKeySuccess(), - defaultSelectedKeyAction(), addMessageNotification(successMessages.ADDED_NEW_KEY(data.keyName)), ] expect(store.getActions()).toEqual(expectedActions) @@ -1090,7 +1087,6 @@ describe('keys slice', () => { const expectedActions = [ addKey(), addKeySuccess(), - defaultSelectedKeyAction(), addMessageNotification(successMessages.ADDED_NEW_KEY(data.keyName)), ] expect(store.getActions()).toEqual(expectedActions) @@ -1116,7 +1112,6 @@ describe('keys slice', () => { const expectedActions = [ addKey(), addKeySuccess(), - defaultSelectedKeyAction(), addMessageNotification(successMessages.ADDED_NEW_KEY(data.keyName)), ] expect(store.getActions()).toEqual(expectedActions) @@ -1141,7 +1136,6 @@ describe('keys slice', () => { const expectedActions = [ addKey(), addKeySuccess(), - defaultSelectedKeyAction(), addMessageNotification(successMessages.ADDED_NEW_KEY(data.keyName)), ] expect(store.getActions()).toEqual(expectedActions) diff --git a/redisinsight/ui/src/slices/tests/list.spec.ts b/redisinsight/ui/src/slices/tests/browser/list.spec.ts similarity index 99% rename from redisinsight/ui/src/slices/tests/list.spec.ts rename to redisinsight/ui/src/slices/tests/browser/list.spec.ts index e5d4eb7d85..4e4f286da5 100644 --- a/redisinsight/ui/src/slices/tests/list.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/list.spec.ts @@ -15,7 +15,7 @@ import { deleteKeySuccess, refreshKeyInfo, updateSelectedKeyRefreshTime, -} from '../keys' +} from '../../browser/keys' import reducer, { initialState, setListInitialState, @@ -47,8 +47,8 @@ import reducer, { deleteListElements, deleteListElementsSuccess, deleteListElementsFailure -} from '../list' -import { addErrorNotification, addMessageNotification } from '../app/notifications' +} from '../../browser/list' +import { addErrorNotification, addMessageNotification } from '../../app/notifications' jest.mock('uiSrc/services') diff --git a/redisinsight/ui/src/slices/tests/rejson.spec.ts b/redisinsight/ui/src/slices/tests/browser/rejson.spec.ts similarity index 99% rename from redisinsight/ui/src/slices/tests/rejson.spec.ts rename to redisinsight/ui/src/slices/tests/browser/rejson.spec.ts index f389985f09..0ca9036c16 100644 --- a/redisinsight/ui/src/slices/tests/rejson.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/rejson.spec.ts @@ -29,9 +29,9 @@ import reducer, { setReJSONDataAction, appendReJSONArrayItemAction, removeReJSONKeyAction, -} from '../rejson' -import { addErrorNotification, addMessageNotification } from '../app/notifications' -import { refreshKeyInfo } from '../keys' +} from '../../browser/rejson' +import { addErrorNotification, addMessageNotification } from '../../app/notifications' +import { refreshKeyInfo } from '../../browser/keys' jest.mock('uiSrc/services') diff --git a/redisinsight/ui/src/slices/tests/set.spec.ts b/redisinsight/ui/src/slices/tests/browser/set.spec.ts similarity index 98% rename from redisinsight/ui/src/slices/tests/set.spec.ts rename to redisinsight/ui/src/slices/tests/browser/set.spec.ts index a968320bae..fb89d017e7 100644 --- a/redisinsight/ui/src/slices/tests/set.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/set.spec.ts @@ -15,7 +15,7 @@ import { deleteKeySuccess, refreshKeyInfo, updateSelectedKeyRefreshTime, -} from '../keys' +} from '../../browser/keys' import reducer, { initialState, loadMoreSetMembers, @@ -36,7 +36,7 @@ import reducer, { fetchMoreSetMembers, addSetMembersAction, deleteSetMembers, -} from '../set' +} from '../../browser/set' jest.mock('uiSrc/services') @@ -79,7 +79,7 @@ describe('set slice', () => { } // Act - const nextState = reducer(initialState, loadSetMembers('')) + const nextState = reducer(initialState, loadSetMembers(['', undefined])) // Assert const rootState = Object.assign(initialStateDefault, { @@ -472,7 +472,7 @@ describe('set slice', () => { // Assert const expectedActions = [ - loadSetMembers(data.match), + loadSetMembers([data.match, undefined]), loadSetMembersSuccess(responsePayload.data), updateSelectedKeyRefreshTime(Date.now()), ] @@ -495,7 +495,7 @@ describe('set slice', () => { // Assert const expectedActions = [ - loadSetMembers('*'), + loadSetMembers(['*', undefined]), addErrorNotification(responsePayload as AxiosError), loadSetMembersFailure(errorMessage), ] diff --git a/redisinsight/ui/src/slices/tests/browser/stream.spec.ts b/redisinsight/ui/src/slices/tests/browser/stream.spec.ts new file mode 100644 index 0000000000..0b947a96aa --- /dev/null +++ b/redisinsight/ui/src/slices/tests/browser/stream.spec.ts @@ -0,0 +1,496 @@ +import { AxiosError } from 'axios' +import { cloneDeep } from 'lodash' +import { DEFAULT_SLOWLOG_DURATION_UNIT, SortOrder } from 'uiSrc/constants' +import { apiService } from 'uiSrc/services' +import reducer, { + addNewEntries, + addNewEntriesFailure, + addNewEntriesSuccess, + cleanRangeFilter, + fetchStreamEntries, + initialState, + loadEntries, + loadEntriesFailure, + loadEntriesSuccess, + loadMoreEntries, + loadMoreEntriesFailure, + loadMoreEntriesSuccess, refreshStreamEntries, + removeEntriesFromList, + removeStreamEntries, + removeStreamEntriesFailure, + removeStreamEntriesSuccess, + streamRangeSelector, + streamSelector, + updateEnd, + updateStart +} from 'uiSrc/slices/browser/stream' +import { fetchSlowLogsAction, getSlowLogs, getSlowLogsError, getSlowLogsSuccess } from 'uiSrc/slices/slowlog/slowlog' +import { cleanup, initialStateDefault, mockedStore, } from 'uiSrc/utils/test-utils' +import { addErrorNotification } from '../../app/notifications' + +jest.mock('uiSrc/services') + +let store: typeof mockedStore + +const mockedData = { + keyName: 'stream_example', + total: 1, + lastGeneratedId: '1652942518810-0', + firstEntry: { + id: '1652942518810-0', + fields: { 1: '2' } + }, + lastEntry: { + id: '1652942518810-0', + fields: { 1: '2' } + }, + entries: [{ + id: '1652942518810-0', + fields: { 1: '2' } + }] +} + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('stream slice', () => { + describe('reducer, actions and selectors', () => { + it('should return the initial state on first run', () => { + // Arrange + const nextState = initialState + + // Act + const result = reducer(undefined, {}) + + // Assert + expect(result).toEqual(nextState) + }) + }) + + describe('loadEntries', () => { + it('should properly set the state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + loading: true, + } + + // Act + const nextState = reducer(initialState, loadEntries(true)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('loadEntriesSuccess', () => { + it('should properly set the state with fetched data', () => { + // Arrange + + const state = { + ...initialState, + loading: false, + error: '', + data: { + ...mockedData, + }, + } + + // Act + const nextState = reducer(initialState, loadEntriesSuccess([mockedData, SortOrder.DESC])) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('loadEntriesFailure', () => { + it('should properly set the state after failed fetched data', () => { + // Arrange + const error = 'Some error' + const state = { + ...initialState, + loading: false, + error, + } + + // Act + const nextState = reducer(initialState, loadEntriesFailure(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('loadMoreEntries', () => { + it('should properly set the state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + loading: true, + } + + // Act + const nextState = reducer(initialState, loadMoreEntries(true)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('loadMoreEntriesSuccess', () => { + it('should properly set the state with fetched data', () => { + // Arrange + + const state = { + ...initialState, + loading: false, + error: '', + data: { + ...mockedData, + }, + } + + // Act + const nextState = reducer(initialState, loadMoreEntriesSuccess(mockedData)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('loadMoreEntriesFailure', () => { + it('should properly set the state after failed fetched data', () => { + // Arrange + const error = 'Some error' + const state = { + ...initialState, + loading: false, + error, + } + + // Act + const nextState = reducer(initialState, loadMoreEntriesFailure(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('addNewEntries', () => { + it('should properly set the state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + loading: true, + } + + // Act + const nextState = reducer(initialState, addNewEntries()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('addNewEntriesSuccess', () => { + it('should properly set the state with fetched data', () => { + // Arrange + + const state = { + ...initialState, + loading: false + } + + // Act + const nextState = reducer(initialState, addNewEntriesSuccess()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('addNewEntriesFailure', () => { + it('should properly set the state after failed fetched data', () => { + // Arrange + const error = 'Some error' + const state = { + ...initialState, + loading: false, + error, + } + + // Act + const nextState = reducer(initialState, addNewEntriesFailure(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('removeStreamEntries', () => { + it('should properly set the state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + loading: true, + } + + // Act + const nextState = reducer(initialState, removeStreamEntries()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('removeStreamEntriesSuccess', () => { + it('should properly set the state with fetched data', () => { + // Arrange + + const state = { + ...initialState, + loading: false + } + + // Act + const nextState = reducer(initialState, removeStreamEntriesSuccess()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('removeStreamEntriesFailure', () => { + it('should properly set the state after failed fetched data', () => { + // Arrange + const error = 'Some error' + const state = { + ...initialState, + loading: false, + error, + } + + // Act + const nextState = reducer(initialState, removeStreamEntriesFailure(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('updateStart', () => { + it('should properly set the state', () => { + // Arrange + const state = { + ...initialState, + range: { + ...initialState.range, + start: '10' + } + } + + // Act + const nextState = reducer(initialState, updateStart('10')) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('updateEnd', () => { + it('should properly set the state', () => { + // Arrange + const state = { + ...initialState, + range: { + ...initialState.range, + end: '100' + } + } + + // Act + const nextState = reducer(initialState, updateEnd('100')) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamSelector(rootState)).toEqual(state) + }) + }) + + describe('cleanRangeFilter', () => { + it('should properly set the state', () => { + // Arrange + const startState = { + ...initialState, + range: { + ...initialState.range, + start: '100', + end: '200' + } + } + const stateRange = { + ...initialState.range + } + + // Act + const nextState = reducer(startState, cleanRangeFilter()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { stream: nextState }, + }) + expect(streamRangeSelector(rootState)).toEqual(stateRange) + }) + }) + + describe('thunks', () => { + describe('fetchStreamEntries', () => { + it('succeed to fetch data', async () => { + // Arrange + const responsePayload = { data: mockedData, status: 200 } + + apiService.post = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(fetchStreamEntries( + mockedData.keyName, + 500, + SortOrder.DESC, + true + )) + + // Assert + const expectedActions = [ + loadEntries(true), + loadEntriesSuccess([mockedData, SortOrder.DESC]), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to fetch data', async () => { + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.post = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(fetchStreamEntries( + mockedData.keyName, + 500, + SortOrder.DESC, + true + )) + + // Assert + const expectedActions = [ + loadEntries(true), + addErrorNotification(responsePayload as AxiosError), + loadEntriesFailure(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + + describe('refreshStreamEntries', () => { + it('succeed to fetch data', async () => { + // Arrange + const responsePayload = { data: mockedData, status: 200 } + + apiService.post = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(refreshStreamEntries( + mockedData.keyName, + true + )) + + // Assert + const expectedActions = [ + loadEntries(true), + loadEntriesSuccess([mockedData, SortOrder.DESC]), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to fetch data', async () => { + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.post = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(refreshStreamEntries( + mockedData.keyName, + true + )) + + // Assert + const expectedActions = [ + loadEntries(true), + addErrorNotification(responsePayload as AxiosError), + loadEntriesFailure(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + }) +}) diff --git a/redisinsight/ui/src/slices/tests/string.spec.ts b/redisinsight/ui/src/slices/tests/browser/string.spec.ts similarity index 99% rename from redisinsight/ui/src/slices/tests/string.spec.ts rename to redisinsight/ui/src/slices/tests/browser/string.spec.ts index 66bf49068f..ae298c5ada 100644 --- a/redisinsight/ui/src/slices/tests/string.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/string.spec.ts @@ -3,7 +3,7 @@ import { cloneDeep } from 'lodash' import { apiService } from 'uiSrc/services' import { cleanup, initialStateDefault, mockedStore } from 'uiSrc/utils/test-utils' import { addErrorNotification } from 'uiSrc/slices/app/notifications' -import { refreshKeyInfo } from '../keys' +import { refreshKeyInfo } from '../../browser/keys' import reducer, { initialState, getString, @@ -17,7 +17,7 @@ import reducer, { updateValueFailure, resetStringValue, updateStringValueAction -} from '../string' +} from '../../browser/string' let store: typeof mockedStore beforeEach(() => { diff --git a/redisinsight/ui/src/slices/tests/zset.spec.ts b/redisinsight/ui/src/slices/tests/browser/zset.spec.ts similarity index 98% rename from redisinsight/ui/src/slices/tests/zset.spec.ts rename to redisinsight/ui/src/slices/tests/browser/zset.spec.ts index 1957dfedc6..5dc92fa2f2 100644 --- a/redisinsight/ui/src/slices/tests/zset.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/zset.spec.ts @@ -17,7 +17,7 @@ import { deleteKeySuccess, refreshKeyInfo, updateSelectedKeyRefreshTime, -} from '../keys' +} from '../../browser/keys' import reducer, { initialState, setZsetInitialState, @@ -51,7 +51,7 @@ import reducer, { fetchSearchZSetMembers, fetchSearchMoreZSetMembers, refreshZsetMembersAction, -} from '../zset' +} from '../../browser/zset' jest.mock('uiSrc/services') @@ -106,7 +106,7 @@ describe('zset slice', () => { } // Act - const nextState = reducer(initialState, loadZSetMembers(initialState.data.sortOrder)) + const nextState = reducer(initialState, loadZSetMembers([initialState.data.sortOrder, undefined])) // Assert const rootState = Object.assign(initialStateDefault, { @@ -748,7 +748,7 @@ describe('zset slice', () => { // Assert const expectedActions = [ - loadZSetMembers(SortOrder.ASC), + loadZSetMembers([SortOrder.ASC, undefined]), loadZSetMembersSuccess(responsePayload.data), updateSelectedKeyRefreshTime(Date.now()), ] @@ -774,7 +774,7 @@ describe('zset slice', () => { // Assert const expectedActions = [ - loadZSetMembers(SortOrder.ASC), + loadZSetMembers([SortOrder.ASC, undefined]), addErrorNotification(responsePayload as AxiosError), loadZSetMembersFailure(errorMessage), ] @@ -1165,7 +1165,7 @@ describe('zset slice', () => { // Assert const expectedActions = [ - loadZSetMembers(SortOrder.ASC), + loadZSetMembers([SortOrder.ASC, undefined]), loadZSetMembersSuccess(responsePayload.data), ] @@ -1190,7 +1190,7 @@ describe('zset slice', () => { // Assert const expectedActions = [ - loadZSetMembers(SortOrder.ASC), + loadZSetMembers([SortOrder.ASC, undefined]), addErrorNotification(responsePayload as AxiosError), loadZSetMembersFailure(errorMessage), ] diff --git a/redisinsight/ui/src/slices/tests/caCerts.spec.ts b/redisinsight/ui/src/slices/tests/instances/caCerts.spec.ts similarity index 97% rename from redisinsight/ui/src/slices/tests/caCerts.spec.ts rename to redisinsight/ui/src/slices/tests/instances/caCerts.spec.ts index b35da87961..b6210295f8 100644 --- a/redisinsight/ui/src/slices/tests/caCerts.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/caCerts.spec.ts @@ -13,8 +13,8 @@ import reducer, { loadCaCertsFailure, caCertsSelector, fetchCaCerts, -} from '../caCerts' -import { addErrorNotification } from '../app/notifications' +} from '../../instances/caCerts' +import { addErrorNotification } from '../../app/notifications' jest.mock('uiSrc/services') diff --git a/redisinsight/ui/src/slices/tests/clientCerts.spec.ts b/redisinsight/ui/src/slices/tests/instances/clientCerts.spec.ts similarity index 98% rename from redisinsight/ui/src/slices/tests/clientCerts.spec.ts rename to redisinsight/ui/src/slices/tests/instances/clientCerts.spec.ts index e58624271f..2508a5de7a 100644 --- a/redisinsight/ui/src/slices/tests/clientCerts.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/clientCerts.spec.ts @@ -8,7 +8,7 @@ import reducer, { loadClientCertsFailure, clientCertsSelector, fetchClientCerts, -} from '../clientCerts' +} from '../../instances/clientCerts' jest.mock('uiSrc/services') diff --git a/redisinsight/ui/src/slices/tests/cloud.spec.ts b/redisinsight/ui/src/slices/tests/instances/cloud.spec.ts similarity index 99% rename from redisinsight/ui/src/slices/tests/cloud.spec.ts rename to redisinsight/ui/src/slices/tests/instances/cloud.spec.ts index 6fa8ec9f76..a114fb9966 100644 --- a/redisinsight/ui/src/slices/tests/cloud.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/cloud.spec.ts @@ -29,9 +29,9 @@ import reducer, { createInstancesRedisCloudFailure, createInstancesRedisCloud, createInstancesRedisCloudSuccess, -} from '../cloud' -import { LoadedCloud } from '../interfaces' -import { addErrorNotification } from '../app/notifications' +} from '../../instances/cloud' +import { LoadedCloud } from '../../interfaces' +import { addErrorNotification } from '../../app/notifications' jest.mock('uiSrc/services') diff --git a/redisinsight/ui/src/slices/tests/cluster.spec.ts b/redisinsight/ui/src/slices/tests/instances/cluster.spec.ts similarity index 99% rename from redisinsight/ui/src/slices/tests/cluster.spec.ts rename to redisinsight/ui/src/slices/tests/instances/cluster.spec.ts index 7099ca92bd..279984129b 100644 --- a/redisinsight/ui/src/slices/tests/cluster.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/cluster.spec.ts @@ -22,9 +22,9 @@ import reducer, { createInstancesRedisClusterSuccess, createInstancesRedisClusterFailure, addInstancesRedisCluster, -} from '../cluster' +} from '../../instances/cluster' -import { addErrorNotification } from '../app/notifications' +import { addErrorNotification } from '../../app/notifications' jest.mock('uiSrc/services') diff --git a/redisinsight/ui/src/slices/tests/instances.spec.ts b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts similarity index 99% rename from redisinsight/ui/src/slices/tests/instances.spec.ts rename to redisinsight/ui/src/slices/tests/instances/instances.spec.ts index fb7bbab2e8..9c5a0c8abe 100644 --- a/redisinsight/ui/src/slices/tests/instances.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts @@ -37,10 +37,10 @@ import reducer, { changeInstanceAliasSuccess, changeInstanceAliasAction, resetConnectedInstance, -} from '../instances' -import { addErrorNotification, addMessageNotification, IAddInstanceErrorPayload } from '../app/notifications' -import { ConnectionType, InitialStateInstances, Instance } from '../interfaces' -import { loadMastersSentinel } from '../sentinel' +} from '../../instances/instances' +import { addErrorNotification, addMessageNotification, IAddInstanceErrorPayload } from '../../app/notifications' +import { ConnectionType, InitialStateInstances, Instance } from '../../interfaces' +import { loadMastersSentinel } from '../../instances/sentinel' jest.mock('uiSrc/services') jest.mock('uiSrc/constants') diff --git a/redisinsight/ui/src/slices/tests/sentinel.spec.ts b/redisinsight/ui/src/slices/tests/instances/sentinel.spec.ts similarity index 98% rename from redisinsight/ui/src/slices/tests/sentinel.spec.ts rename to redisinsight/ui/src/slices/tests/instances/sentinel.spec.ts index 260c81fd62..814d71e6c2 100644 --- a/redisinsight/ui/src/slices/tests/sentinel.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/sentinel.spec.ts @@ -24,9 +24,9 @@ import reducer, { createMastersSentinel, createMastersSentinelFailure, updateMastersSentinel, -} from '../sentinel' -import { addErrorNotification } from '../app/notifications' -import { LoadedSentinel, ModifiedSentinelMaster } from '../interfaces' +} from '../../instances/sentinel' +import { addErrorNotification } from '../../app/notifications' +import { LoadedSentinel, ModifiedSentinelMaster } from '../../interfaces' jest.mock('uiSrc/services') diff --git a/redisinsight/ui/src/slices/tests/slowlog/slowlog.spec.ts b/redisinsight/ui/src/slices/tests/slowlog/slowlog.spec.ts new file mode 100644 index 0000000000..a6cef02ba9 --- /dev/null +++ b/redisinsight/ui/src/slices/tests/slowlog/slowlog.spec.ts @@ -0,0 +1,484 @@ +import { cloneDeep } from 'lodash' +import { AxiosError } from 'axios' +import { SlowLog, SlowLogConfig } from 'src/modules/slow-log/models' +import { DEFAULT_SLOWLOG_DURATION_UNIT } from 'uiSrc/constants' + +import { apiService } from 'uiSrc/services' +import { cleanup, mockedStore, initialStateDefault } from 'uiSrc/utils/test-utils' +import { addErrorNotification } from 'uiSrc/slices/app/notifications' + +import reducer, { + initialState, + getSlowLogConfig, + getSlowLogConfigSuccess, + getSlowLogConfigError, + getSlowLogs, + getSlowLogsSuccess, + getSlowLogsError, + clearSlowLogAction, + deleteSlowLogs, + deleteSlowLogsError, + deleteSlowLogsSuccess, + fetchSlowLogsAction, + getSlowLogConfigAction, + patchSlowLogConfigAction, + setSlowLogInitialState, + slowLogSelector +} from '../../slowlog/slowlog' + +const timestamp = 1629128049027 +let store: typeof mockedStore +let dateNow: jest.SpyInstance + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('slowLog slice', () => { + beforeAll(() => { + dateNow = jest.spyOn(Date, 'now').mockImplementation(() => timestamp) + }) + + afterAll(() => { + dateNow.mockRestore() + }) + + describe('reducer, actions and selectors', () => { + it('should return the initial state on first run', () => { + // Arrange + const nextState = initialState + + // Act + const result = reducer(undefined, {}) + + // Assert + expect(result).toEqual(nextState) + }) + }) + + describe('setUserSettingsInitialState', () => { + it('should properly set the initial state', () => { + // Arrange + const state = { + ...initialState + } + + // Act + const nextState = reducer(initialState, setSlowLogInitialState()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + slowlog: nextState, + }) + expect(slowLogSelector(rootState)).toEqual(state) + }) + }) + + describe('getSlowLogs', () => { + it('should properly set state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + loading: true + } + + // Act + const nextState = reducer(initialState, getSlowLogs()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + slowlog: nextState, + }) + expect(slowLogSelector(rootState)).toEqual(state) + }) + }) + + describe('getSlowLogsSuccess', () => { + it('should properly set state after success fetch data', () => { + // Arrange + const data: SlowLog[] = [ + { + id: 1, + time: 1652265051, + durationUs: 199, + args: 'SET foo bar', + source: '127.17.0.1:46922' + } + ] + const state = { + ...initialState, + loading: false, + data, + durationUnit: DEFAULT_SLOWLOG_DURATION_UNIT, + lastRefreshTime: timestamp + } + + // Act + const nextState = reducer(initialState, getSlowLogsSuccess([data, DEFAULT_SLOWLOG_DURATION_UNIT])) + + // Assert + const rootState = Object.assign(initialStateDefault, { + slowlog: nextState, + }) + expect(slowLogSelector(rootState)).toEqual(state) + }) + }) + + describe('getSlowLogsError', () => { + it('should properly set state after failed fetch data', () => { + // Arrange + const error = 'Some error' + const state = { + ...initialState, + loading: false, + error + } + + // Act + const nextState = reducer(initialState, getSlowLogsError(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + slowlog: nextState, + }) + expect(slowLogSelector(rootState)).toEqual(state) + }) + }) + + describe('deleteSlowLogs', () => { + it('should properly set state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + loading: true + } + + // Act + const nextState = reducer(initialState, getSlowLogs()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + slowlog: nextState, + }) + expect(slowLogSelector(rootState)).toEqual(state) + }) + }) + + describe('deleteSlowLogsSuccess', () => { + it('should properly set state after success fetch data', () => { + // Arrange + const state = { + ...initialState, + loading: false, + data: [] + } + + // Act + const nextState = reducer(initialState, deleteSlowLogsSuccess()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + slowlog: nextState, + }) + expect(slowLogSelector(rootState)).toEqual(state) + }) + }) + + describe('deleteSlowLogsError', () => { + it('should properly set state after failed fetch data', () => { + // Arrange + const error = 'Some error' + const state = { + ...initialState, + loading: false, + error + } + + // Act + const nextState = reducer(initialState, deleteSlowLogsError(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + slowlog: nextState, + }) + expect(slowLogSelector(rootState)).toEqual(state) + }) + }) + + describe('getSlowLogConfig', () => { + it('should properly set state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + loading: true + } + + // Act + const nextState = reducer(initialState, getSlowLogConfig()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + slowlog: nextState, + }) + expect(slowLogSelector(rootState)).toEqual(state) + }) + }) + + describe('getSlowLogConfigSuccess', () => { + it('should properly set state after success fetch data', () => { + // Arrange + const config: SlowLogConfig = { + slowlogMaxLen: 100, + slowlogLogSlowerThan: 300, + } + const state = { + ...initialState, + loading: false, + config + } + + // Act + const nextState = reducer(initialState, getSlowLogConfigSuccess([config, null])) + + // Assert + const rootState = Object.assign(initialStateDefault, { + slowlog: nextState, + }) + expect(slowLogSelector(rootState)).toEqual(state) + }) + }) + + describe('getSlowLogConfigError', () => { + it('should properly set state after failed fetch data', () => { + // Arrange + const error = 'Some error' + const state = { + ...initialState, + loading: false, + error + } + + // Act + const nextState = reducer(initialState, getSlowLogConfigError(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + slowlog: nextState, + }) + expect(slowLogSelector(rootState)).toEqual(state) + }) + }) + + // thunks + describe('thunks', () => { + describe('fetchSlowLogsAction', () => { + it('succeed to fetch data', async () => { + // Arrange + const data: SlowLog[] = [ + { + id: 1, + time: 1652265051, + durationUs: 199, + args: 'SET foo bar', + source: '127.17.0.1:46922' + } + ] + const responsePayload = { data, status: 200 } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(fetchSlowLogsAction('123', 100)) + + // Assert + const expectedActions = [ + getSlowLogs(), + getSlowLogsSuccess([data, DEFAULT_SLOWLOG_DURATION_UNIT]), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to fetch data', async () => { + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.get = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(fetchSlowLogsAction('123', 100)) + + // Assert + const expectedActions = [ + getSlowLogs(), + addErrorNotification(responsePayload as AxiosError), + getSlowLogsError(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + + describe('clearSlowLogAction', () => { + it('succeed to fetch data', async () => { + const responsePayload = { status: 200 } + + apiService.delete = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(clearSlowLogAction('123')) + + // Assert + const expectedActions = [ + deleteSlowLogs(), + deleteSlowLogsSuccess(), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to fetch data', async () => { + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.delete = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(clearSlowLogAction('123')) + + // Assert + const expectedActions = [ + deleteSlowLogs(), + addErrorNotification(responsePayload as AxiosError), + deleteSlowLogsError(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + + describe('getSlowLogConfigAction', () => { + it('succeed to fetch data', async () => { + const data = { + slowlogMaxLen: 100, + slowlogLogSlowerThan: 1200 + } + const responsePayload = { status: 200, data } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(getSlowLogConfigAction('123')) + + // Assert + const expectedActions = [ + getSlowLogConfig(), + getSlowLogConfigSuccess([data, null]), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to fetch data', async () => { + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.get = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(getSlowLogConfigAction('123')) + + // Assert + const expectedActions = [ + getSlowLogConfig(), + addErrorNotification(responsePayload as AxiosError), + getSlowLogConfigError(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + + describe('patchSlowLogConfigAction', () => { + it('succeed to fetch data', async () => { + const data = { + slowlogMaxLen: 100, + slowlogLogSlowerThan: 1200 + } + const config = { + ...data + } + const responsePayload = { status: 200, data } + + apiService.patch = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch( + patchSlowLogConfigAction( + '123', + config, + DEFAULT_SLOWLOG_DURATION_UNIT + ) + ) + + // Assert + const expectedActions = [ + getSlowLogConfig(), + getSlowLogConfigSuccess([data, DEFAULT_SLOWLOG_DURATION_UNIT]), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to fetch data', async () => { + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.patch = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch( + patchSlowLogConfigAction( + '123', + { + slowlogMaxLen: 100, + slowlogLogSlowerThan: 1200 + }, + DEFAULT_SLOWLOG_DURATION_UNIT + ) + ) + + // Assert + const expectedActions = [ + getSlowLogConfig(), + addErrorNotification(responsePayload as AxiosError), + getSlowLogConfigError(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + }) +}) diff --git a/redisinsight/ui/src/styles/base/_helpers.scss b/redisinsight/ui/src/styles/base/_helpers.scss index a4bec64135..3214203539 100644 --- a/redisinsight/ui/src/styles/base/_helpers.scss +++ b/redisinsight/ui/src/styles/base/_helpers.scss @@ -23,6 +23,15 @@ justify-content: space-between; } +.line-clamp-2 { + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box !important; +} + .relative { position: relative; } @@ -75,3 +84,14 @@ opacity: 1; } } + +.popover-without-top-tail { + margin-top: -8px; + + .euiPopover__panelArrow { + &::before, + &::after { + content: none !important; + } + } +} diff --git a/redisinsight/ui/src/styles/base/_inputs.scss b/redisinsight/ui/src/styles/base/_inputs.scss index a988e5e57e..cc4f1d0b12 100644 --- a/redisinsight/ui/src/styles/base/_inputs.scss +++ b/redisinsight/ui/src/styles/base/_inputs.scss @@ -47,5 +47,5 @@ input[name='sentinelMasterPassword'] ~ .euiFormControlLayoutIcons { .euiFormControlLayout--compressed .inputAppendIcon > svg, .euiFormControlLayout--compressed > .euiFormControlLayout__append { width: 34px !important; - height: 30px !important; + height: 29px !important; } diff --git a/redisinsight/ui/src/styles/base/_links.scss b/redisinsight/ui/src/styles/base/_links.scss index 247d92d350..64f02963a7 100644 --- a/redisinsight/ui/src/styles/base/_links.scss +++ b/redisinsight/ui/src/styles/base/_links.scss @@ -19,3 +19,9 @@ a[target="_blank"], a.euiLink[target="_blank"] { color: var(--externalLinkTooltipColor) !important; } } + +.euiToast { + a, a.euiLink { + color: var(--linkToastColor) !important; + } +} diff --git a/redisinsight/ui/src/styles/base/_typography.scss b/redisinsight/ui/src/styles/base/_typography.scss index 7e096d7c57..83a68c85fb 100644 --- a/redisinsight/ui/src/styles/base/_typography.scss +++ b/redisinsight/ui/src/styles/base/_typography.scss @@ -13,6 +13,10 @@ body { letter-spacing: -0.13px; } + .euiSuperSelect__item .euiContextMenuItem__text { + line-height: 16px; + } + .euiText { font-size: 0.875rem; letter-spacing: -0.14px; @@ -41,17 +45,20 @@ body { } body { - input, textarea, select, button { + input, + textarea, + select, + button { font-family: 'Graphik', sans-serif; } } - .euiSuperSelectControl { font-family: 'Graphik', sans-serif !important; } -.euiText, .euiTitle { +.euiText, +.euiTitle { color: var(--htmlColor) !important; } @@ -59,15 +66,17 @@ body { color: var(--htmlColor); } -.euiText h4, .euiText dt { - color: var(--euiTextSubduedColor) !important; +.euiText h4, +.euiText dt { + color: var(--euiTextSubduedColor) !important; } .euiTextColor--danger { color: var(--euiColorDangerText) !important; } -.euiTextColor--warning.warning--light, .euiIcon--warning.warning--light { +.euiTextColor--warning.warning--light, +.euiIcon--warning.warning--light { color: var(--euiColorWarningLight) !important; } diff --git a/redisinsight/ui/src/styles/components/_buttons.scss b/redisinsight/ui/src/styles/components/_buttons.scss index 2047a4a9e9..79ae2441dc 100644 --- a/redisinsight/ui/src/styles/components/_buttons.scss +++ b/redisinsight/ui/src/styles/components/_buttons.scss @@ -113,3 +113,25 @@ } } } + +.euiButtonEmpty.euiButtonEmpty--primary { + border-radius: 4px; + color: var(--euiTextSubduedColor); + background-color: transparent; + transition: color ease 0.3s, background-color ease 0.3s; + + .euiButtonEmpty__text, .euiText { + transition: color ease 0.3s; + color: var(--euiTextSubduedColor) !important; + } + + &:hover, &:focus { + text-decoration: none !important; + background-color: var(--hoverInListColorDarken); + color: var(--htmlColor); + + .euiButtonEmpty__text, .euiText { + color: var(--htmlColor) !important; + } + } +} diff --git a/redisinsight/ui/src/styles/components/_forms.scss b/redisinsight/ui/src/styles/components/_forms.scss index b7eb5e8c12..e1ca6f5176 100644 --- a/redisinsight/ui/src/styles/components/_forms.scss +++ b/redisinsight/ui/src/styles/components/_forms.scss @@ -24,7 +24,7 @@ display: flex; align-items: center; font-size: 13px; - line-height: 43px; + line-height: 41px; } .euiFormRow__label { diff --git a/redisinsight/ui/src/styles/components/_switch.scss b/redisinsight/ui/src/styles/components/_switch.scss index 760d56a8c7..07e10d0368 100644 --- a/redisinsight/ui/src/styles/components/_switch.scss +++ b/redisinsight/ui/src/styles/components/_switch.scss @@ -5,9 +5,8 @@ .euiSwitch__thumb { background-color: var(--euiColorPrimaryText) !important; - border-color: transparent !important; } - .euiSwitch__button[aria-checked='false'] .euiSwitch__body { + .euiSwitch__button[aria-checked="false"] .euiSwitch__body { background-color: #707070 !important; } .euiSwitch__body { @@ -18,3 +17,9 @@ } } } + +.euiSwitch:not(.euiSwitch--compressed) { + .euiSwitch__thumb { + border-color: transparent !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 4bf083ec4b..7c6bcd80ff 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 @@ -91,6 +91,7 @@ --hoverInListColorDarken: #{$hoverInListColorDarken}; --externalLinkColor: #{$externalLinkColor}; --externalLinkTooltipColor: #{$externalLinkTooltipColor}; + --linkToastColor: #{$linkToastColor}; --browserViewTypePassive: #{$browserViewTypePassive}; --browserComponentActive: #{$browserComponentActive}; --browserTreeNodeOpen: #{$browserTreeNodeOpen}; 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 6694d5c080..300f0d6ff6 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -36,7 +36,8 @@ $euiButtonIconSecondary: $euiColorSecondary; $htmlColor: #dfe5ef; $navBackgroundColor: #0f1633; $externalLinkColor: #8ba2ff; -$externalLinkTooltipColor: #8ba2ff; +$externalLinkTooltipColor: $externalLinkColor; +$linkToastColor: $externalLinkColor; $tableRowHoverColor: #070707; $tableRowSelectedColor: #212536; 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 6c41a2b34c..fe7656b24b 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 @@ -93,6 +93,7 @@ --hoverInListColorDarken: #{$hoverInListColorLight}; --externalLinkColor: #{$externalLinkColor}; --externalLinkTooltipColor: #{$externalLinkTooltipColor}; + --linkToastColor: #{$linkToastColor}; --browserViewTypePassive: #{$browserViewTypePassive}; --browserComponentActive: #{$browserComponentActive}; --browserTreeNodeOpen: #{$browserTreeNodeOpen}; 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 500d33d50f..72e8d29268 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -33,6 +33,7 @@ $htmlColor: #173369; $navBackgroundColor: #0f1633; $externalLinkColor: #3163d8; $externalLinkTooltipColor: #3163d8; +$linkToastColor: #ADBEFF; $tableRowHoverColor: #fafbfd; $tableRowSelectedColor: #ebeffa; diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index 3bb00cc833..022f7f5d0b 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -52,6 +52,10 @@ export enum TelemetryEvent { BROWSER_KEYS_SCANNED = 'BROWSER_KEYS_SCANNED', BROWSER_KEYS_ADDITIONALLY_SCANNED = 'BROWSER_KEYS_ADDITIONALLY_SCANNED', BROWSER_KEYS_SCANNED_WITH_FILTER_ENABLED = 'BROWSER_KEYS_SCANNED_WITH_FILTER_ENABLED', + BROWSER_KEY_LIST_AUTO_REFRESH_ENABLED = 'BROWSER_KEY_LIST_AUTO_REFRESH_ENABLED', + BROWSER_KEY_LIST_AUTO_REFRESH_DISABLED = 'BROWSER_KEY_LIST_AUTO_REFRESH_DISABLED', + BROWSER_KEY_DETAILS_AUTO_REFRESH_ENABLED = 'BROWSER_KEY_DETAILS_AUTO_REFRESH_ENABLED', + BROWSER_KEY_DETAILS_AUTO_REFRESH_DISABLED = 'BROWSER_KEY_DETAILS_AUTO_REFRESH_DISABLED', CLI_OPENED = 'CLI_OPENED', CLI_CLOSED = 'CLI_CLOSED', @@ -114,4 +118,19 @@ export enum TelemetryEvent { TREE_VIEW_KEYS_ADDITIONALLY_SCANNED = 'TREE_VIEW_KEYS_ADDITIONALLY_SCANNED', TREE_VIEW_DELIMITER_CHANGED = 'TREE_VIEW_DELIMITER_CHANGED', TREE_VIEW_KEY_ADDED = 'TREE_VIEW_KEY_ADDED', + TREE_VIEW_KEY_LIST_AUTO_REFRESH_ENABLED = 'TREE_VIEW_KEY_LIST_AUTO_REFRESH_ENABLED', + TREE_VIEW_KEY_LIST_AUTO_REFRESH_DISABLED = 'TREE_VIEW_KEY_LIST_AUTO_REFRESH_DISABLED', + TREE_VIEW_KEY_DETAILS_AUTO_REFRESH_ENABLED = 'TREE_VIEW_KEY_DETAILS_AUTO_REFRESH_ENABLED', + TREE_VIEW_KEY_DETAILS_AUTO_REFRESH_DISABLED = 'TREE_VIEW_KEY_DETAILS_AUTO_REFRESH_DISABLED', + + SLOWLOG_LOADED = 'SLOWLOG_LOADED', + SLOWLOG_CLEARED = 'SLOWLOG_CLEARED', + SLOWLOG_SET_LOG_SLOWER_THAN = 'SLOWLOG_SET_LOG_SLOWER_THAN', + SLOWLOG_SET_MAX_LEN = 'SLOWLOG_SET_MAX_LEN', + SLOWLOG_SORTED = 'SLOWLOG_SORTED', + SLOWLOG_AUTO_REFRESH_ENABLED = 'SLOWLOG_AUTO_REFRESH_ENABLED', + SLOWLOG_AUTO_REFRESH_DISABLED = 'SLOWLOG_AUTO_REFRESH_DISABLED', + + STREAM_DATA_FILTERED = 'STREAM_DATA_FILTERED', + STREAM_DATA_FILTER_RESET = 'STREAM_DATA_FILTER_RESET' } diff --git a/redisinsight/ui/src/telemetry/pageViews.ts b/redisinsight/ui/src/telemetry/pageViews.ts index b9f7e478d1..ae25e594be 100644 --- a/redisinsight/ui/src/telemetry/pageViews.ts +++ b/redisinsight/ui/src/telemetry/pageViews.ts @@ -4,4 +4,5 @@ export enum TelemetryPageView { SETTINGS_PAGE = 'Settings', BROWSER_PAGE = 'Browser', WORKBENCH_PAGE = 'Workbench', + SLOWLOG_PAGE = 'Slow Log' } diff --git a/redisinsight/ui/src/telemetry/segment.ts b/redisinsight/ui/src/telemetry/segment.ts index 28d5492cc2..8fc42c9f6c 100644 --- a/redisinsight/ui/src/telemetry/segment.ts +++ b/redisinsight/ui/src/telemetry/segment.ts @@ -32,7 +32,11 @@ export class SegmentTelemetryService implements ITelemetryService { if (!isWebApp) { pageObject.page = { - path: '', url: '', title: globalThis.document.title + path: '', url: '', title: '' + } + } else { + pageObject.page = { + ...pageObject.page, title: '' } } diff --git a/redisinsight/ui/src/telemetry/telemetryUtils.ts b/redisinsight/ui/src/telemetry/telemetryUtils.ts index 96f8dec1af..42c77f9cdd 100644 --- a/redisinsight/ui/src/telemetry/telemetryUtils.ts +++ b/redisinsight/ui/src/telemetry/telemetryUtils.ts @@ -152,6 +152,12 @@ const getAdditionalAddedEventData = (endpoint: ApiEndpoints, data: any) => { keyType: KeyTypes.ReJSON, TTL: -1 } + case ApiEndpoints.STREAMS: + return { + keyType: KeyTypes.Stream, + length: 1, + TTL: data.expire || -1 + } default: return {} } diff --git a/redisinsight/ui/src/utils/longNames.ts b/redisinsight/ui/src/utils/longNames.tsx similarity index 77% rename from redisinsight/ui/src/utils/longNames.ts rename to redisinsight/ui/src/utils/longNames.tsx index 684c5d5da3..a65fd40001 100644 --- a/redisinsight/ui/src/utils/longNames.ts +++ b/redisinsight/ui/src/utils/longNames.tsx @@ -1,3 +1,4 @@ +import React from 'react' import replaceSpaces from './replaceSpaces' export function formatLongName( @@ -26,3 +27,13 @@ export function getDbIndex(db: number = 0) { export const truncateText = (text = '', maxLength = 0, separator = '...') => (text.length >= maxLength ? text.slice(0, maxLength) + separator : text) + +export const createDeleteFieldHeader = (keyName: string) => formatNameShort(keyName) + +export const createDeleteFieldMessage = (field: string) => ( + <> + will be removed from + {' '} + {formatNameShort(field)} + +) diff --git a/redisinsight/ui/src/utils/numbers.ts b/redisinsight/ui/src/utils/numbers.ts index b0c58a2aa8..9164d5c847 100644 --- a/redisinsight/ui/src/utils/numbers.ts +++ b/redisinsight/ui/src/utils/numbers.ts @@ -2,4 +2,4 @@ import { toNumber } from 'lodash' export const isNaNConvertedString = (value: string): boolean => Number.isNaN(toNumber(value)) -export const numberWithSpaces = (number: number) => number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ') +export const numberWithSpaces = (number: number = 0) => number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ') diff --git a/redisinsight/ui/src/utils/parseResponse.ts b/redisinsight/ui/src/utils/parseResponse.ts index d2420d1cb3..453aa34439 100644 --- a/redisinsight/ui/src/utils/parseResponse.ts +++ b/redisinsight/ui/src/utils/parseResponse.ts @@ -1,6 +1,6 @@ import { find, map, sortBy, omit, forEach } from 'lodash' import { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' -import { initialStateSentinelStatus } from 'uiSrc/slices/sentinel' +import { initialStateSentinelStatus } from 'uiSrc/slices/instances/sentinel' import { AddSentinelMasterResponse } from 'apiSrc/modules/instances/dto/redis-sentinel.dto' import { SentinelMaster } from 'apiSrc/modules/redis-sentinel/models/sentinel' diff --git a/redisinsight/ui/src/utils/replaceSpaces.ts b/redisinsight/ui/src/utils/replaceSpaces.ts index dfded2f26e..198eef54e0 100644 --- a/redisinsight/ui/src/utils/replaceSpaces.ts +++ b/redisinsight/ui/src/utils/replaceSpaces.ts @@ -2,5 +2,5 @@ export default function replaceSpaces(text: string | number = '') { if (text === ' ') { return '\u00a0' } - return text.toString().replace(/\s\s/g, '\u00a0\u00a0') + return text?.toString().replace(/\s\s/g, '\u00a0\u00a0') ?? '' } diff --git a/redisinsight/ui/src/utils/streamUtils.ts b/redisinsight/ui/src/utils/streamUtils.ts new file mode 100644 index 0000000000..845b7aad63 --- /dev/null +++ b/redisinsight/ui/src/utils/streamUtils.ts @@ -0,0 +1,22 @@ +import { format } from 'date-fns' +import { SCAN_STREAM_START_DEFAULT, SCAN_STREAM_END_DEFAULT } from 'uiSrc/constants/api' + +export const getFormatTime = (time: string = '') => + format(new Date(+time), 'HH:mm:ss.SSS d MMM yyyy') + +export const getTimestampFromId = (id: string = ''): number => + parseInt(id.split('-')[0], 10) + +export const getStreamRangeStart = (start: string, firstEntryId: string) => { + if (start === '' || !firstEntryId || start === getTimestampFromId(firstEntryId).toString()) { + return SCAN_STREAM_START_DEFAULT + } + return start +} + +export const getStreamRangeEnd = (end: string, endEtryId: string) => { + if (end === '' || !endEtryId || end === getTimestampFromId(endEtryId).toString()) { + return SCAN_STREAM_END_DEFAULT + } + return end +} diff --git a/redisinsight/ui/src/utils/test-utils.tsx b/redisinsight/ui/src/utils/test-utils.tsx index 861fd79658..037b5e7563 100644 --- a/redisinsight/ui/src/utils/test-utils.tsx +++ b/redisinsight/ui/src/utils/test-utils.tsx @@ -8,19 +8,20 @@ import configureMockStore from 'redux-mock-store' import { render as rtlRender } from '@testing-library/react' import rootStore, { RootState } from 'uiSrc/slices/store' -import { initialState as initialStateInstances } from 'uiSrc/slices/instances' -import { initialState as initialStateCaCerts } from 'uiSrc/slices/caCerts' -import { initialState as initialStateClientCerts } from 'uiSrc/slices/clientCerts' -import { initialState as initialStateCluster } from 'uiSrc/slices/cluster' -import { initialState as initialStateCloud } from 'uiSrc/slices/cloud' -import { initialState as initialStateSentinel } from 'uiSrc/slices/sentinel' -import { initialState as initialStateKeys } from 'uiSrc/slices/keys' -import { initialState as initialStateString } from 'uiSrc/slices/string' -import { initialState as initialStateZSet } from 'uiSrc/slices/zset' -import { initialState as initialStateSet } from 'uiSrc/slices/set' -import { initialState as initialStateHash } from 'uiSrc/slices/hash' -import { initialState as initialStateList } from 'uiSrc/slices/list' -import { initialState as initialStateRejson } from 'uiSrc/slices/rejson' +import { initialState as initialStateInstances } from 'uiSrc/slices/instances/instances' +import { initialState as initialStateCaCerts } from 'uiSrc/slices/instances/caCerts' +import { initialState as initialStateClientCerts } from 'uiSrc/slices/instances/clientCerts' +import { initialState as initialStateCluster } from 'uiSrc/slices/instances/cluster' +import { initialState as initialStateCloud } from 'uiSrc/slices/instances/cloud' +import { initialState as initialStateSentinel } from 'uiSrc/slices/instances/sentinel' +import { initialState as initialStateKeys } from 'uiSrc/slices/browser/keys' +import { initialState as initialStateString } from 'uiSrc/slices/browser/string' +import { initialState as initialStateZSet } from 'uiSrc/slices/browser/zset' +import { initialState as initialStateSet } from 'uiSrc/slices/browser/set' +import { initialState as initialStateHash } from 'uiSrc/slices/browser/hash' +import { initialState as initialStateList } from 'uiSrc/slices/browser/list' +import { initialState as initialStateRejson } from 'uiSrc/slices/browser/rejson' +import { initialState as initialStateStream } from 'uiSrc/slices/browser/stream' import { initialState as initialStateNotifications } from 'uiSrc/slices/app/notifications' import { initialState as initialStateAppInfo } from 'uiSrc/slices/app/info' import { initialState as initialStateAppContext } from 'uiSrc/slices/app/context' @@ -34,6 +35,7 @@ import { initialState as initialStateWBResults } from 'uiSrc/slices/workbench/wb import { initialState as initialStateWBEGuides } from 'uiSrc/slices/workbench/wb-guides' import { initialState as initialStateWBETutorials } from 'uiSrc/slices/workbench/wb-tutorials' import { initialState as initialStateCreateRedisButtons } from 'uiSrc/slices/content/create-redis-buttons' +import { initialState as initialStateSlowLog } from 'uiSrc/slices/slowlog/slowlog' interface Options { initialState?: RootState; @@ -67,6 +69,7 @@ const initialStateDefault: RootState = { hash: cloneDeep(initialStateHash), list: cloneDeep(initialStateList), rejson: cloneDeep(initialStateRejson), + stream: cloneDeep(initialStateStream), }, cli: { settings: cloneDeep(initialStateCliSettings), @@ -83,7 +86,8 @@ const initialStateDefault: RootState = { }, content: { createRedisButtons: cloneDeep(initialStateCreateRedisButtons) - } + }, + slowlog: cloneDeep(initialStateSlowLog) } // mocked store diff --git a/redisinsight/ui/src/utils/tests/truncateTTL.spec.ts b/redisinsight/ui/src/utils/tests/truncateTTL.spec.ts index 21db04b953..d5eac14b71 100644 --- a/redisinsight/ui/src/utils/tests/truncateTTL.spec.ts +++ b/redisinsight/ui/src/utils/tests/truncateTTL.spec.ts @@ -1,6 +1,6 @@ import { truncateTTLToDuration, - truncateTTLToFirstUnit, + truncateNumberToFirstUnit, truncateTTLToRange, truncateTTLToSeconds, } from '../truncateTTL' @@ -133,14 +133,14 @@ describe('Truncate TTL util tests', () => { }) }) - describe('truncateTTLToFirstUnit', () => { - it('truncateTTLToFirstUnit should return appropriate value', () => { - const ttl1 = 100 - const ttl2 = 1_534 - const ttl3 = 54_334 - const ttl4 = 4_325_634 - const ttl5 = 112_012_330 - const ttl6 = 2_120_042_300 + describe('truncateNumberToFirstUnit', () => { + it('truncateNumberToFirstUnit should return appropriate value', () => { + const number1 = 100 + const number2 = 1_534 + const number3 = 54_334 + const number4 = 4_325_634 + const number5 = 112_012_330 + const number6 = 2_120_042_300 const expectedResponse1 = '1 min' // '1 min, 40 s' const expectedResponse2 = '25 min' // '25 min, 34 s' @@ -149,12 +149,12 @@ describe('Truncate TTL util tests', () => { const expectedResponse5 = '3 yr' // '3 yr, 6 mo, 19 d, 10 h, 32 min, 10 s' const expectedResponse6 = '67 yr' // '67 yr, 2 mo, 6 d, 12 h, 38 min, 20 s' - expect(truncateTTLToFirstUnit(ttl1)).toEqual(expectedResponse1) - expect(truncateTTLToFirstUnit(ttl2)).toEqual(expectedResponse2) - expect(truncateTTLToFirstUnit(ttl3)).toEqual(expectedResponse3) - expect(truncateTTLToFirstUnit(ttl4)).toEqual(expectedResponse4) - expect(truncateTTLToFirstUnit(ttl5)).toEqual(expectedResponse5) - expect(truncateTTLToFirstUnit(ttl6)).toEqual(expectedResponse6) + expect(truncateNumberToFirstUnit(number1)).toEqual(expectedResponse1) + expect(truncateNumberToFirstUnit(number2)).toEqual(expectedResponse2) + expect(truncateNumberToFirstUnit(number3)).toEqual(expectedResponse3) + expect(truncateNumberToFirstUnit(number4)).toEqual(expectedResponse4) + expect(truncateNumberToFirstUnit(number5)).toEqual(expectedResponse5) + expect(truncateNumberToFirstUnit(number6)).toEqual(expectedResponse6) }) }) }) diff --git a/redisinsight/ui/src/utils/tests/validations.spec.ts b/redisinsight/ui/src/utils/tests/validations.spec.ts index 8449af4851..352dbd24db 100644 --- a/redisinsight/ui/src/utils/tests/validations.spec.ts +++ b/redisinsight/ui/src/utils/tests/validations.spec.ts @@ -8,9 +8,11 @@ import { validateCountNumber, validateScoreNumber, validateTTLNumberForAddKey, - MAX_DATABASE_INDEX_NUMBER, - validateDatabaseNumber, - validateCertName + validateCertName, + validateRefreshRateNumber, + MAX_REFRESH_RATE, + errorValidateRefreshRateNumber, + errorValidateNegativeInteger, } from '../validations' const text1 = '123 123 123' @@ -114,8 +116,8 @@ describe('Validations utils', () => { it('validatePortNumber should return only numbers between 0 and MAX_PORT_NUMBER', () => { const expectedResponse1 = `${MAX_PORT_NUMBER}` const expectedResponse2 = '12312' - const expectedResponse4 = '0' - const expectedResponse5 = '0' + const expectedResponse4 = '' + const expectedResponse5 = '' const expectedResponse6 = '2323' const expectedResponse7 = `${MAX_PORT_NUMBER}` const expectedResponse8 = `${MAX_PORT_NUMBER}` @@ -130,28 +132,6 @@ describe('Validations utils', () => { }) }) - describe('validateDatabaseNumber', () => { - it('validateDatabaseNumber should return only numbers between 0 and MAX_DATABASE_INDEX_NUMBER', () => { - const expectedResponse1 = `${MAX_DATABASE_INDEX_NUMBER}` - const expectedResponse2 = `${MAX_DATABASE_INDEX_NUMBER}` - const expectedResponse4 = '0' - const expectedResponse5 = '0' - const expectedResponse6 = `${MAX_DATABASE_INDEX_NUMBER}` - const expectedResponse7 = `${MAX_DATABASE_INDEX_NUMBER}` - const expectedResponse8 = `${MAX_DATABASE_INDEX_NUMBER}` - const expectedResponse13 = '5' - - expect(validateDatabaseNumber(text1)).toEqual(expectedResponse1) - expect(validateDatabaseNumber(text2)).toEqual(expectedResponse2) - expect(validateDatabaseNumber(text4)).toEqual(expectedResponse4) - expect(validateDatabaseNumber(text5)).toEqual(expectedResponse5) - expect(validateDatabaseNumber(text6)).toEqual(expectedResponse6) - expect(validateDatabaseNumber(text7)).toEqual(expectedResponse7) - expect(validateDatabaseNumber(text8)).toEqual(expectedResponse8) - expect(validateDatabaseNumber(text13)).toEqual(expectedResponse13) - }) - }) - describe('validateEmail', () => { it('validateEmail should return "true" only for email format text', () => { expect(validateEmail(text1)).toBeFalsy() @@ -178,4 +158,70 @@ describe('Validations utils', () => { expect(result).toBe(expected) }) }) + + describe('validateRefreshRateNumber', () => { + it.each([ + [text1, `${MAX_REFRESH_RATE}`], + [text2, `${MAX_REFRESH_RATE}`], + [text3, ''], + [text4, '.'], + [text5, '.'], + [text6, `${MAX_REFRESH_RATE}`], + [text7, `${MAX_REFRESH_RATE}`], + [text8, `${MAX_REFRESH_RATE}`], + [text9, `${MAX_REFRESH_RATE}`], + [text10, '348.3'], + [text12, '32'], + [text13, '5'], + + ])('for input: %s (input), should be output: %s', + (input, expected) => { + const result = validateRefreshRateNumber(input) + expect(result).toBe(expected) + }) + }) + + describe('errorValidateRefreshRateNumber', () => { + it.each([ + [validateRefreshRateNumber(text1), false], + [validateRefreshRateNumber(text2), false], + [validateRefreshRateNumber(text3), true], + [validateRefreshRateNumber(text4), true], + [validateRefreshRateNumber(text5), true], + [validateRefreshRateNumber(text6), false], + [validateRefreshRateNumber(text7), false], + [validateRefreshRateNumber(text8), false], + [validateRefreshRateNumber(text9), false], + [validateRefreshRateNumber(text10), false], + [validateRefreshRateNumber(text12), false], + [validateRefreshRateNumber(text13), false], + + ])('for input: %s (input), should be output: %s', + (input, expected) => { + const result = errorValidateRefreshRateNumber(input) + expect(result).toBe(expected) + }) + }) + + describe('errorValidateNegativeInteger', () => { + it.each([ + [validateRefreshRateNumber(text1), true], + [validateRefreshRateNumber(text2), true], + [validateRefreshRateNumber(text3), true], + [validateRefreshRateNumber(text4), true], + [validateRefreshRateNumber(text5), true], + [validateRefreshRateNumber(text6), true], + [validateRefreshRateNumber(text7), true], + [validateRefreshRateNumber(text8), true], + [validateRefreshRateNumber(text9), true], + [validateRefreshRateNumber(text10), true], + [validateRefreshRateNumber(text12), false], + [validateRefreshRateNumber(text13), false], + + ])('for input: %s (input), should be output: %s', + (input, expected) => { + const result = errorValidateNegativeInteger(input) + expect(result).toBe(expected) + }) + }) }) diff --git a/redisinsight/ui/src/utils/truncateTTL.ts b/redisinsight/ui/src/utils/truncateTTL.ts index 3fa95cb23c..f3a749c4c9 100644 --- a/redisinsight/ui/src/utils/truncateTTL.ts +++ b/redisinsight/ui/src/utils/truncateTTL.ts @@ -78,5 +78,5 @@ export const truncateTTLToDuration = (ttl: number): string => { export const truncateTTLToSeconds = (ttl: number) => ttl?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ') ?? '' -export const truncateTTLToFirstUnit = (ttl: number): string => +export const truncateNumberToFirstUnit = (ttl: number): string => truncateTTLToDuration(ttl).split(TRUNCATE_DELIMITER)[0] diff --git a/redisinsight/ui/src/utils/validations.ts b/redisinsight/ui/src/utils/validations.ts index 1e841b28a7..38c53de6e6 100644 --- a/redisinsight/ui/src/utils/validations.ts +++ b/redisinsight/ui/src/utils/validations.ts @@ -1,10 +1,17 @@ +import { floor } from 'lodash' + export const MAX_TTL_NUMBER = 2147483647 export const MAX_PORT_NUMBER = 65535 -export const MAX_DATABASE_INDEX_NUMBER = 99 export const MAX_SCORE_DECIMAL_LENGTH = 15 +export const MAX_REFRESH_RATE = 999.9 +export const MIN_REFRESH_RATE = 1.0 + +export const entryIdRegex = /^(\*)$|^(([0-9]+)(-)((\*)$|([0-9]+$)))/ export const validateField = (text: string) => text.replace(/\s/g, '') +export const validateEntryId = (initValue: string) => initValue.replace(/[^0-9-*]+/gi, '') + export const validateCountNumber = (initValue: string) => { const value = initValue.replace(/[^0-9]+/gi, '') @@ -53,22 +60,57 @@ export const validateEmail = (email: string) => { } export const validatePortNumber = (initValue: string) => validateNumber(initValue, MAX_PORT_NUMBER) -export const validateDatabaseNumber = (initValue: string) => - validateNumber(initValue, MAX_DATABASE_INDEX_NUMBER) -export const validateNumber = (initValue: string, maxNumber: number = MAX_PORT_NUMBER) => { - const value = initValue ? +initValue.replace(/[^0-9]+/gi, '') : '' +export const validateNumber = (initValue: string, maxNumber: number = Infinity, minNumber: number = 0) => { + const positiveNumbers = /[^0-9]+/gi + const negativeNumbers = /[^0-9-]+/gi + const value = initValue ? initValue.replace(minNumber < 0 ? negativeNumbers : positiveNumbers, '') : '' - if (value > maxNumber) { + if (+value > maxNumber) { return maxNumber.toString() } - if (value < 0) { + if (+value < minNumber) { return '' } return value.toString() } +export const validateRefreshRateNumber = (initValue: string) => { + let value = initValue.replace(/[^0-9.]/gi, '') + + if (countDecimals(+value) > 0) { + value = `${floor(+value, 1)}` + } + + if (+value > MAX_REFRESH_RATE) { + return MAX_REFRESH_RATE.toString() + } + + if (+value < 0) { + return '' + } + + return value.toString() +} + +export const errorValidateRefreshRateNumber = (value: string) => { + const decimalsRegexp = /^\d+(\.\d{1})?$/ + return !decimalsRegexp.test(value) +} + +export const errorValidateNegativeInteger = (value: string) => { + const negativeIntegerRegexp = /^-?\d+$/ + return !negativeIntegerRegexp.test(value) +} + export const validateCertName = (initValue: string) => initValue.replace(/[^ a-zA-Z0-9!@#$%^&*-_()[\]]+/gi, '').toString() + +export const isRequiredStringsValid = (...params: string[]) => params.every((p = '') => p.length > 0) + +const countDecimals = (value: number) => { + if (Math.floor(value) === value) return 0 + return value.toString().split('.')?.[1]?.length || 0 +} diff --git a/scripts/build-statics.sh b/scripts/build-statics.sh index a27e46c84d..73eff63518 100644 --- a/scripts/build-statics.sh +++ b/scripts/build-statics.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -e # =============== Plugins =============== PLUGINS_DIR="./redisinsight/api/static/plugins" diff --git a/tests/e2e/desktop.runner.ts b/tests/e2e/desktop.runner.ts index 6e4e9af5c6..c3cef7e720 100644 --- a/tests/e2e/desktop.runner.ts +++ b/tests/e2e/desktop.runner.ts @@ -27,8 +27,8 @@ import testcafe from 'testcafe'; speed: 1 }); }) - .then(() => { - process.exit(0); + .then((failedCount) => { + process.exit(failedCount); }) .catch((e) => { console.error(e) diff --git a/tests/e2e/desktop.runner.win.ts b/tests/e2e/desktop.runner.win.ts index b2e6834fe2..f6410cbd9b 100644 --- a/tests/e2e/desktop.runner.win.ts +++ b/tests/e2e/desktop.runner.win.ts @@ -27,8 +27,8 @@ import testcafe from 'testcafe'; speed: 1 }); }) - .then(() => { - process.exit(0); + .then((failedCount) => { + process.exit(failedCount); }) .catch((e) => { console.error(e); diff --git a/tests/e2e/helpers/common.ts b/tests/e2e/helpers/common.ts index bcf0aa54be..47a44dac04 100644 --- a/tests/e2e/helpers/common.ts +++ b/tests/e2e/helpers/common.ts @@ -1,6 +1,6 @@ import {RequestMock, t} from 'testcafe'; -import {commonUrl} from "./conf"; import { Chance } from 'chance'; +import {commonUrl} from './conf'; const settingsApiUrl = `${commonUrl}/api/settings`; const chance = new Chance(); @@ -10,15 +10,15 @@ const mockedSettingsResponse = { version: '0', eula: false, analytics: false - }} + }}; export class Common { mock = RequestMock() - .onRequestTo(settingsApiUrl) - .respond(mockedSettingsResponse, 200); + .onRequestTo(settingsApiUrl) + .respond(mockedSettingsResponse, 200); - async waitForElementNotVisible(elm): Promise { - await t.expect(elm.exists).notOk({ timeout: 20000 }); + async waitForElementNotVisible(elm: Selector): Promise { + await t.expect(elm.exists).notOk({ timeout: 10000 }); } /** @@ -26,7 +26,7 @@ export class Common { * @param length The amount of array elements */ createArrayWithKeys(length: number): string[] { - return Array.from({length}, (_, i) => `key${i}`) + return Array.from({length}, (_, i) => `key${i}`); } /** @@ -48,7 +48,7 @@ export class Common { * @param length The amount of array elements * @param keyName The name of the key */ - async createArrayWithKeyValueAndKeyname(length: number, keyName: string): Promise { + async createArrayWithKeyValueAndKeyname(length: number, keyName: string): Promise { const keyNameArray = []; for(let i = 1; i <= length; i++) { const key = `${keyName}${i}`; @@ -63,7 +63,7 @@ export class Common { * @param length The amount of array elements */ createArrayPairsWithKeyValue(length: number): [string, number][] { - return Array.from({ length }, (_, i) => [`key${i}`, i]) + return Array.from({ length }, (_, i) => [`key${i}`, i]); } /** @@ -82,7 +82,7 @@ export class Common { * Get background colour of element * @param element The selector of the element */ - async getBackgroundColour(element: Selector): Promise { + async getBackgroundColour(element: Selector): Promise { return element.getStyleProperty('background-color'); } } diff --git a/tests/e2e/helpers/database.ts b/tests/e2e/helpers/database.ts index 6ccc6e2de1..c2f33cf0a0 100644 --- a/tests/e2e/helpers/database.ts +++ b/tests/e2e/helpers/database.ts @@ -43,9 +43,9 @@ export async function discoverSentinelDatabase(databaseParameters: SentinelParam // Click for autodiscover await t .click(addRedisDatabasePage.discoverSentinelDatabaseButton) - .expect(discoverMasterGroupsPage.addPrimaryGroupButton.exists).ok('Verify that user is on the second step of Sentinel flow', { timeout: 60000 }); + .expect(discoverMasterGroupsPage.addPrimaryGroupButton.exists).ok('Verify that user is on the second step of Sentinel flow', { timeout: 10000 }); // Select Master Groups and Add to RedisInsight - await discoverMasterGroupsPage.addMasterGroups() + await discoverMasterGroupsPage.addMasterGroups(); await t.click(autoDiscoverREDatabases.viewDatabasesButton); } @@ -59,7 +59,7 @@ export async function addNewREClusterDatabase(databaseParameters: AddNewDatabase //Click on submit button await t.click(addRedisDatabasePage.addRedisDatabaseButton); //Wait for database to be exist in the list of Autodiscover databases and select it - await t.expect(autoDiscoverREDatabases.databaseNames.withExactText(databaseParameters.databaseName).exists).ok('The existence of the database', { timeout: 60000 }); + await t.expect(autoDiscoverREDatabases.databaseNames.withExactText(databaseParameters.databaseName).exists).ok('The existence of the database', { timeout: 10000 }); await t.typeText(autoDiscoverREDatabases.search, databaseParameters.databaseName); await t.click(autoDiscoverREDatabases.databaseCheckbox); //Click Add selected databases button @@ -77,9 +77,9 @@ export async function addOSSClusterDatabase(databaseParameters: OSSClusterParame //Click for saving await t.click(addRedisDatabasePage.addRedisDatabaseButton); //Check for info message that DB was added - await t.expect(myRedisDatabasePage.databaseInfoMessage.exists).ok('Check that info message exists', { timeout: 60000 }); + await t.expect(myRedisDatabasePage.databaseInfoMessage.exists).ok('Check that info message exists', { timeout: 10000 }); //Wait for database to be exist - await t.expect(myRedisDatabasePage.dbNameList.withExactText(databaseParameters.ossClusterDatabaseName).exists).ok('The existence of the database', { timeout: 60000 }); + await t.expect(myRedisDatabasePage.dbNameList.withExactText(databaseParameters.ossClusterDatabaseName).exists).ok('The existence of the database', { timeout: 10000 }); } /** @@ -100,7 +100,7 @@ export async function addNewRECloudDatabase(cloudAPIAccessKey: string, cloudAPIS await t.click(autoDiscoverREDatabases.addSelectedDatabases); //Wait for database to be exist in the My redis databases list await t.click(autoDiscoverREDatabases.viewDatabasesButton); - await t.expect(myRedisDatabasePage.dbNameList.withExactText(databaseName).exists).ok('The existence of the database', { timeout: 60000 }); + await t.expect(myRedisDatabasePage.dbNameList.withExactText(databaseName).exists).ok('The existence of the database', { timeout: 10000 }); return databaseName; } @@ -171,7 +171,8 @@ export async function deleteDatabase(databaseName: string): Promise { export async function acceptTermsAddDatabaseOrConnectToRedisStack(databaseParameters: AddNewDatabaseParameters, databaseName: string): Promise { if(await addRedisDatabasePage.addDatabaseButton.visible) { await acceptLicenseTermsAndAddDatabase(databaseParameters, databaseName); - } else { + } + else { await acceptLicenseAndConnectToRedisStack(); } } diff --git a/tests/e2e/pageObjects/add-redis-database-page.ts b/tests/e2e/pageObjects/add-redis-database-page.ts index 70edae27f5..f1899c1bbe 100644 --- a/tests/e2e/pageObjects/add-redis-database-page.ts +++ b/tests/e2e/pageObjects/add-redis-database-page.ts @@ -40,19 +40,19 @@ export class AddRedisDatabasePage { * @param parameters the parameters of the database */ async addRedisDataBase(parameters: AddNewDatabaseParameters): Promise { - const addDatabaseButtonElement = await this.addDatabaseButton.with({ visibilityCheck: true, timeout: 30000 })(); + await this.addDatabaseButton.with({ visibilityCheck: true, timeout: 10000 })(); await t .click(this.addDatabaseButton) - .click(this.addDatabaseManually) + .click(this.addDatabaseManually); await t .typeText(this.hostInput, parameters.host, { replace: true, paste: true }) .typeText(this.portInput, parameters.port, { replace: true, paste: true }) - .typeText(this.databaseAliasInput, parameters.databaseName, { replace: true, paste: true }) + .typeText(this.databaseAliasInput, parameters.databaseName, { replace: true, paste: true }); if (!!parameters.databaseUsername) { - await t.typeText(this.usernameInput, parameters.databaseUsername, { replace: true, paste: true }) + await t.typeText(this.usernameInput, parameters.databaseUsername, { replace: true, paste: true }); } if (!!parameters.databasePassword) { - await t.typeText(this.passwordInput, parameters.databasePassword, { replace: true, paste: true }) + await t.typeText(this.passwordInput, parameters.databasePassword, { replace: true, paste: true }); } } @@ -64,16 +64,16 @@ export class AddRedisDatabasePage { async addLogicalRedisDatabase(parameters: AddNewDatabaseParameters, index: string): Promise { await t .click(this.addDatabaseButton) - .click(this.addDatabaseManually) + .click(this.addDatabaseManually); await t .typeText(this.hostInput, parameters.host, { replace: true, paste: true }) .typeText(this.portInput, parameters.port, { replace: true, paste: true }) - .typeText(this.databaseAliasInput, parameters.databaseName, { replace: true, paste: true }) + .typeText(this.databaseAliasInput, parameters.databaseName, { replace: true, paste: true }); if (!!parameters.databaseUsername) { - await t.typeText(this.usernameInput, parameters.databaseUsername, { replace: true, paste: true }) + await t.typeText(this.usernameInput, parameters.databaseUsername, { replace: true, paste: true }); } if (!!parameters.databasePassword) { - await t.typeText(this.passwordInput, parameters.databasePassword, { replace: true, paste: true }) + await t.typeText(this.passwordInput, parameters.databasePassword, { replace: true, paste: true }); } //Enter logical index await t.click(this.databaseIndexCheckbox); @@ -90,15 +90,15 @@ export class AddRedisDatabasePage { await t .click(this.addDatabaseButton) .click(this.addAutoDiscoverDatabase) - .click(this.redisSentinelType) + .click(this.redisSentinelType); if (!!parameters.sentinelHost) { - await t.typeText(this.hostInput, parameters.sentinelHost, { replace: true, paste: true }) + await t.typeText(this.hostInput, parameters.sentinelHost, { replace: true, paste: true }); } if (!!parameters.sentinelPort) { - await t.typeText(this.portInput, parameters.sentinelPort, { replace: true, paste: true }) + await t.typeText(this.portInput, parameters.sentinelPort, { replace: true, paste: true }); } if (!!parameters.sentinelPassword) { - await t.typeText(this.passwordInput, parameters.sentinelPassword, { replace: true, paste: true }) + await t.typeText(this.passwordInput, parameters.sentinelPassword, { replace: true, paste: true }); } } @@ -110,12 +110,12 @@ export class AddRedisDatabasePage { await t .click(this.addDatabaseButton) .click(this.addAutoDiscoverDatabase) - .click(this.redisClusterType) + .click(this.redisClusterType); await t .typeText(this.hostInput, parameters.host, { replace: true, paste: true }) .typeText(this.portInput, parameters.port, { replace: true, paste: true }) .typeText(this.usernameInput, parameters.databaseUsername, { replace: true, paste: true }) - .typeText(this.passwordInput, parameters.databasePassword, { replace: true, paste: true }) + .typeText(this.passwordInput, parameters.databasePassword, { replace: true, paste: true }); } /** @@ -126,10 +126,10 @@ export class AddRedisDatabasePage { await t .click(this.addDatabaseButton) .click(this.addAutoDiscoverDatabase) - .click(this.redisCloudProType) + .click(this.redisCloudProType); await t .typeText(this.accessKeyInput, cloudAPIAccessKey, { replace: true, paste: true }) - .typeText(this.secretKeyInput, cloudAPISecretKey, { replace: true, paste: true }) + .typeText(this.secretKeyInput, cloudAPISecretKey, { replace: true, paste: true }); } /** @@ -139,15 +139,15 @@ export class AddRedisDatabasePage { async addOssClusterDatabase(parameters: OSSClusterParameters): Promise { await t .click(this.addDatabaseButton) - .click(this.addDatabaseManually) + .click(this.addDatabaseManually); if (!!parameters.ossClusterHost) { - await t.typeText(this.hostInput, parameters.ossClusterHost, { replace: true, paste: true }) + await t.typeText(this.hostInput, parameters.ossClusterHost, { replace: true, paste: true }); } if (!!parameters.ossClusterPort) { - await t.typeText(this.portInput, parameters.ossClusterPort, { replace: true, paste: true }) + await t.typeText(this.portInput, parameters.ossClusterPort, { replace: true, paste: true }); } if (!!parameters.ossClusterDatabaseName) { - await t.typeText(this.databaseAliasInput, parameters.ossClusterDatabaseName, { replace: true, paste: true }) + await t.typeText(this.databaseAliasInput, parameters.ossClusterDatabaseName, { replace: true, paste: true }); } } } @@ -166,7 +166,7 @@ export type AddNewDatabaseParameters = { databaseName?: string, databaseUsername?: string, databasePassword?: string -} +}; /** * Add new database parameters @@ -178,7 +178,7 @@ export type SentinelParameters = { sentinelHost: string, sentinelPort: string, sentinelPassword?: string -} +}; /** * Add new database parameters @@ -191,4 +191,4 @@ export type OSSClusterParameters = { ossClusterHost: string, ossClusterPort: string, ossClusterDatabaseName: string -} +}; diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index 2405ec1339..0299211b78 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -22,7 +22,7 @@ export class BrowserPage { closeEditTTL = Selector('[data-testid=cancel-btn]'); saveTTLValue = Selector('[data-testid=apply-btn]'); refreshKeysButton = Selector('[data-testid=refresh-keys-btn]'); - refreshKeyButton = Selector('[data-testid=refresh-key-btn]') + refreshKeyButton = Selector('[data-testid=refresh-key-btn]'); applyButton = Selector('[data-testid=apply-btn]'); editKeyNameButton = Selector('[data-testid=edit-key-btn]'); closeKeyButton = Selector('[data-testid=close-key-btn]'); @@ -54,9 +54,6 @@ export class BrowserPage { scanMoreButton = Selector('[data-testid=scan-more]'); resizeBtnKeyList = Selector('[data-test-subj=resize-btn-keyList-keyDetails]'); modulesButton = Selector('[data-testid$=_module]'); - overviewMoreInfo = Selector('[data-testid=overview-more-info-button]'); - overviewTooltip = Selector('[data-testid=overview-more-info-tooltip]'); - overviewTooltipStatTitle = Selector('[data-testid=overview-db-stat-title]'); databaseInfoIcon = Selector('[data-testid=db-info-icon]'); treeViewButton = Selector('[data-testid=view-type-list-btn]'); browserViewButton = Selector('[data-testid=view-type-browser-btn]'); @@ -68,6 +65,12 @@ export class BrowserPage { treeViewDelimiterButton = Selector('[data-testid=tree-view-delimiter-btn]'); treeViewDelimiterValueSave = Selector('[data-testid=apply-btn]'); treeViewDelimiterValueCancel = Selector('[data-testid=cancel-btn]'); + fullScreenModeButton = Selector('[data-testid=toggle-full-screen]'); + closeRightPanel = Selector('[data-testid=close-right-panel-btn]'); + addNewStreamEntry = Selector('[data-testid=add-key-value-items-btn]'); + removeEntryButton = Selector('[data-testid^=remove-entry-button-]'); + confirmRemoveEntryButton = Selector('[data-testid^=remove-entry-button-]').withExactText('Remove'); + clearStreamEntryInputs = Selector('[data-testid=remove-item]'); //LINKS internalLinkToWorkbench = Selector('[data-testid=internal-workbench-link]'); //OPTION ELEMENTS @@ -77,6 +80,7 @@ export class BrowserPage { zsetOption = Selector('#zset'); listOption = Selector('#list'); hashOption = Selector('#hash'); + streamOption = Selector('#stream'); removeFromHeadSelection = Selector('#HEAD'); selectedFilterTypeString = Selector('[data-testid=filter-option-type-selected-string]'); filterOptionType = Selector('[data-test-subj^=filter-option-type-]'); @@ -87,7 +91,7 @@ export class BrowserPage { keyNameInput = Selector('[data-testid=edit-key-input]'); keyTTLInput = Selector('[data-testid=ttl]'); editKeyTTLInput = Selector('[data-testid=edit-ttl-input]'); - ttlText = Selector('[data-testid=key-ttl-text] span') + ttlText = Selector('[data-testid=key-ttl-text] span'); hashFieldValueInput = Selector('[data-testid=field-value]'); hashFieldNameInput = Selector('[data-testid=field-name]'); listKeyElementInput = Selector('[data-testid=element]'); @@ -104,6 +108,11 @@ export class BrowserPage { jsonValueInput = Selector('[data-testid=json-value]'); countInput = Selector('[data-testid=count-input]'); treeViewDelimiterInput = Selector('[data-testid=tree-view-delimiter-input]'); + streamEntryId = Selector('[data-testid=entryId]'); + streamField = Selector('[data-testid=field-name]'); + streamValue = Selector('[data-testid=field-value]'); + addStreamRow = Selector('[data-testid=add-new-item]'); + streamFieldsValues = Selector('[data-testid^=stream-entry-field-]'); //TEXT ELEMENTS keySizeDetails = Selector('[data-testid=key-size-text]'); keyLengthDetails = Selector('[data-testid=key-length-text]'); @@ -151,9 +160,39 @@ export class BrowserPage { multiSearchArea = Selector(this.cssFilteringLabel); keyDetailsHeader = Selector('[data-testid=key-details-header]'); keyListTable = Selector('[data-testid=keyList-table]'); + keyDetailsTable = Selector('[data-testid=key-details]'); keyNameFormDetails = Selector('[data-testid=key-name-text]'); keyDetailsTTL = Selector('[data-testid=key-ttl-text]'); progressLine = Selector('div.euiProgress'); + progressKeyList = Selector('[data-testid=progress-key-list]'); + jsonScalarValue = Selector('[data-testid=json-scalar-value]'); + noKeysToDisplayText = Selector('[data-testid=no-keys-selected-text]'); + virtualTableContainer = Selector('[data-testid=virtual-table-container]'); + streamEntriesContainer = Selector('[data-test-id=stream-entries-container]'); + streamEntryColumns = Selector(this.streamEntriesContainer.find('[aria-colcount]')); + streamEntryRows = Selector(this.streamEntriesContainer.find('[aria-rowcount]')); + streamEntryDate = Selector('[data-testid*=-date][data-testid*=stream-entry]'); + streamEntryIdValue = Selector('.streamEntryId[data-testid*=stream-entry]'); + streamFields = Selector('[data-test-id=stream-entries-container] .truncateText span'); + streamEntryFields = Selector('[data-testid^=stream-entry-field]'); + confirmationMessagePopover = Selector('div.euiPopover__panel .euiText '); + + /** + * Common part for Add any new key + * @param keyName The name of the key + * @param TTL The Time to live value of the key + */ + async commonAddNewKey(keyName: string, TTL?: string): Promise { + await common.waitForElementNotVisible(this.progressLine); + await t.click(this.plusAddKeyButton); + await t.click(this.addKeyNameInput); + await t.typeText(this.addKeyNameInput, keyName); + if (TTL !== undefined) { + await t.click(this.keyTTLInput); + await t.typeText(this.keyTTLInput, TTL); + } + await t.click(this.keyTypeDropDown); + } /** * Adding a new String key @@ -167,7 +206,7 @@ export class BrowserPage { await t.click(this.stringOption); await t.click(this.addKeyNameInput); await t.typeText(this.addKeyNameInput, keyName); - if (TTL) { + if (TTL !== undefined) { await t.click(this.keyTTLInput); await t.typeText(this.keyTTLInput, TTL); } @@ -179,19 +218,21 @@ export class BrowserPage { /** *Adding a new Json key * @param keyName The name of the key - * @param TTL The Time to live value of the key * @param value The key value + * @param TTL The Time to live value of the key (optional parameter) */ - async addJsonKey(keyName: string, TTL = ' ', value = ' '): Promise { + async addJsonKey(keyName: string, value: string, TTL?: string): Promise { await t.click(this.plusAddKeyButton); await t.click(this.keyTypeDropDown); await t.click(this.jsonOption); await t.click(this.addKeyNameInput); await t.typeText(this.addKeyNameInput, keyName); - await t.click(this.keyTTLInput); - await t.typeText(this.keyTTLInput, TTL); await t.click(this.jsonKeyValueInput); - await t.typeText(this.jsonKeyValueInput, value); + await t.typeText(this.jsonKeyValueInput, value, { paste: true }); + if (TTL !== undefined) { + await t.click(this.keyTTLInput); + await t.typeText(this.keyTTLInput, TTL); + } await t.click(this.addKeyButton); } @@ -276,6 +317,73 @@ export class BrowserPage { await t.click(this.addKeyButton); } + /** + * Adding a new Stream key + * @param keyName The name of the key + * @param field The field name of the key + * @param value The value of the key + * @param TTL The Time to live value of the key + */ + async addStreamKey(keyName: string, field = ' ', value = ' ', TTL?: string): Promise { + await this.commonAddNewKey(keyName, TTL); + await t.click(this.streamOption); + // Verify that user can see Entity ID filled by * by default on add Stream key form + await t.expect(this.streamEntryId.withAttribute('value', '*').visible).ok('Preselected Stream Entity ID field'); + await t.typeText(this.streamField, field); + await t.typeText(this.streamValue, value); + await t.expect(this.addKeyButton.withAttribute('disabled').exists).notOk('Clickable Add Key button'); + await t.click(this.addKeyButton); + await t.click(this.toastCloseButton); + } + + /** + * Adding a new Entry to a Stream key + * @param field The field name of the key + * @param value The value of the key + * @param entryId The identification of specific entry of the Stream Key + */ + async addEntryToStream(field: string, value: string, entryId?: string): Promise { + await t.click(this.addNewStreamEntry); + // Specify field, value and add new entry + await t.typeText(this.streamField, field); + await t.typeText(this.streamValue, value); + if (entryId !== undefined) { + await t.typeText(this.streamEntryId, entryId); + } + await t.click(this.saveElementButton); + // Validate that new entry is added + await t.expect(this.streamEntriesContainer.textContent).contains(field, 'Field parameter'); + await t.expect(this.streamEntriesContainer.textContent).contains(value, 'Value parameter'); + } + + /** + * Adding a new Entry to a Stream key + * @param fields The field name of the key + * @param values The value of the key + * @param entryId The identification of specific entry of the Stream Key + */ + async fulfillSeveralStreamFields(fields: string[], values: string[], entryId?: string): Promise { + for (let i = 0; i < fields.length; i++) { + await t.typeText(this.streamField.nth(-1), fields[i]); + await t.typeText(this.streamValue.nth(-1), values[i]); + if (i < fields.length - 1) { + await t.click(this.addStreamRow); + } + } + if (entryId !== undefined) { + await t.typeText(this.streamEntryId, entryId); + } + } + + /** + * Get number of existed columns and rows of Stream key + */ + async getStreamRowColumnNumber(): Promise { + const columnStreamNumber = await this.streamEntriesContainer.find('[aria-colcount]').getAttribute('aria-colcount'); + const rowStreamNumber = await this.streamEntriesContainer.find('[aria-rowcount]').getAttribute('aria-rowcount'); + return [columnStreamNumber, rowStreamNumber]; + } + /** * Select keys filter group type * @param groupName The group name @@ -296,7 +404,7 @@ export class BrowserPage { await t.click(this.filterByPatterSearchInput); await t.pressKey('ctrl+a delete'); await t.typeText(this.filterByPatterSearchInput, keyName); - await t.pressKey('enter') + await t.pressKey('enter'); } /** @@ -305,14 +413,12 @@ export class BrowserPage { */ async isKeyIsDisplayedInTheList(keyName: string): Promise { const keyNameInTheList = Selector(`[data-testid="key-${keyName}"]`); - const res = keyNameInTheList.exists; - return res; + return keyNameInTheList.exists; } //Getting the text of the Notification message async getMessageText(): Promise { - const text = this.notificationMessage.textContent; - return text; + return this.notificationMessage.textContent; } //Delete key from details @@ -365,8 +471,7 @@ export class BrowserPage { //Get string key value from details async getStringKeyValue(): Promise { - const value = this.stringKeyValue.textContent; - return value; + return this.stringKeyValue.textContent; } /** @@ -436,8 +541,7 @@ export class BrowserPage { //Get databases name async getDatabasesName(): Promise { - const text = this.databaseNames.textContent; - return text; + return this.databaseNames.textContent; } //Open key details @@ -462,6 +566,7 @@ export class BrowserPage { //Remove List element from tail for Redis databases less then v. 6.2. async removeListElementFromTailOld(): Promise { await t.click(this.removeElementFromListIconButton); + await t.expect(this.countInput.withAttribute('disabled').exists).ok('Disabled input field'); await t.click(this.removeElementFromListButton); await t.click(this.confirmRemoveListElementButton); } @@ -469,6 +574,7 @@ export class BrowserPage { //Remove List element from head for Redis databases less then v. 6.2. async removeListElementFromHeadOld(): Promise { await t.click(this.removeElementFromListIconButton); + await t.expect(this.countInput.withAttribute('disabled').exists).ok('Disabled input field'); //Select Remove from head selection await t.click(this.removeElementFromListSelect); await t.click(this.removeFromHeadSelection); @@ -545,7 +651,7 @@ export class BrowserPage { * Get Values list of the key * @param element Selector of the element with list */ - async getValuesListByElement(element): Promise { + async getValuesListByElement(element: any): Promise { const keyValues = []; const count = await element.count; for (let i = 0; i < count; i++) { @@ -592,13 +698,14 @@ export class BrowserPage { } // Verify that the last folder level contains required keys const lastSelector = array[array.length - 1].substring(0, array[array.length - 1].length - 2); - const folderSelector = `${lastSelector}keys${delimiter}keys${delimiter}"]` + const folderSelector = `${lastSelector}keys${delimiter}keys${delimiter}"]`; await t.click(await Selector(folderSelector)); const foundKeyName = `${folders[i].join(delimiter)}`; await t.expect(Selector(`[data-testid*="key-${foundKeyName}"]`).visible).ok('Specific key'); await t.click(array[0]); } } + /** * Change delimiter value * @delimiter string with delimiter value @@ -613,6 +720,20 @@ export class BrowserPage { // Click on save button await t.click(this.treeViewDelimiterValueSave); } + + //Delete entry from Stream key + async deleteStreamEntry(): Promise { + await t.click(this.removeEntryButton); + await t.click(this.confirmRemoveEntryButton); + } + + /** + * Get key length from opened key details + */ + async getKeyLength(): Promise { + const rawValue = await this.keyLengthDetails.textContent; + return rawValue.split(' ')[rawValue.split(' ').length - 1]; + } } /** @@ -631,4 +752,4 @@ export type AddNewKeyParameters = { members?: string, scores?: string, field?: string -} +}; diff --git a/tests/e2e/pageObjects/cli-page.ts b/tests/e2e/pageObjects/cli-page.ts index 46c4fb7afe..89c9bc1e36 100644 --- a/tests/e2e/pageObjects/cli-page.ts +++ b/tests/e2e/pageObjects/cli-page.ts @@ -1,9 +1,10 @@ -import { t, Selector } from 'testcafe'; +import { t, Selector, ClientFunction } from 'testcafe'; import { Common } from '../helpers/common'; import { BrowserPage } from '../pageObjects'; const common = new Common(); const browserPage = new BrowserPage(); +const getPageUrl = ClientFunction(() => window.location.href); export class CliPage { //------------------------------------------------------------------------------------------- @@ -48,67 +49,91 @@ export class CliPage { cliEndpoint = Selector('[data-testid^=cli-endpoint]'); cliDbIndex = Selector('[data-testid=cli-db-index]'); - /** + /** * Select filter group type * @param groupName The group name */ - async selectFilterGroupType(groupName: string): Promise{ - await t.click(this.filterGroupTypeButton); - await t.click(this.filterOptionGroupType.withExactText(groupName)); - } + async selectFilterGroupType(groupName: string): Promise{ + await t.click(this.filterGroupTypeButton); + await t.click(this.filterOptionGroupType.withExactText(groupName)); + } - /** + /** * Add keys from CLI * @param keyCommand The command from cli to add key * @param amount The amount of the keys * @param keyName The name of the keys. The default value is keyName */ - async addKeysFromCli(keyCommand: string, amount: number, keyName = 'keyName'): Promise{ - //Open CLI - await t.click(this.cliExpandButton); - //Add keys - const keyValueArray = await common.createArrayWithKeyValueAndKeyname(amount, keyName); - await t.typeText(this.cliCommandInput, `${keyCommand} ${keyValueArray.join(' ')}`, { paste: true }); - await t.pressKey('enter'); - await t.click(this.cliCollapseButton); - } + async addKeysFromCli(keyCommand: string, amount: number, keyName = 'keyName'): Promise{ + //Open CLI + await t.click(this.cliExpandButton); + //Add keys + const keyValueArray = await common.createArrayWithKeyValueAndKeyname(amount, keyName); + await t.typeText(this.cliCommandInput, `${keyCommand} ${keyValueArray.join(' ')}`, { paste: true }); + await t.pressKey('enter'); + await t.click(this.cliCollapseButton); + } - /** + /** * Send command in Cli * @param command The command to send */ - async sendCommandInCli(command: string): Promise{ - //Open CLI - await t.click(this.cliExpandButton); - await t.typeText(this.cliCommandInput, command, { paste: true }); - await t.pressKey('enter'); - await t.click(this.cliCollapseButton); - } + async sendCommandInCli(command: string): Promise{ + //Open CLI + await t.click(this.cliExpandButton); + await t.typeText(this.cliCommandInput, command, { paste: true }); + await t.pressKey('enter'); + await t.click(this.cliCollapseButton); + } - /** + /** * Get command result execution * @param command The command for send in CLI */ - async getSuccessCommandResultFromCli(command: string): Promise{ - //Open CLI - await t.click(this.cliExpandButton); - //Add keys - await t.typeText(this.cliCommandInput, command, { paste: true }); - await t.pressKey('enter'); - const commandResult = await this.cliOutputResponseSuccess.innerText; - await t.click(this.cliCollapseButton); - return commandResult; - } + async getSuccessCommandResultFromCli(command: string): Promise{ + //Open CLI + await t.click(this.cliExpandButton); + //Add keys + await t.typeText(this.cliCommandInput, command, { paste: true }); + await t.pressKey('enter'); + const commandResult = await this.cliOutputResponseSuccess.innerText; + await t.click(this.cliCollapseButton); + return commandResult; + } - /** + /** * Send command in Cli and wait for total keys after 5 seconds * @param command The command to send */ - async sendCliCommandAndWaitForTotalKeys(command: string): Promise { - await this.sendCommandInCli(command); - //Wait 5 seconds and return total keys - await t.wait(5000); - const totalKeys = await browserPage.overviewTotalKeys.innerText; - return totalKeys; - } + async sendCliCommandAndWaitForTotalKeys(command: string): Promise { + await this.sendCommandInCli(command); + //Wait 5 seconds and return total keys + await t.wait(5000); + return await browserPage.overviewTotalKeys.innerText; + } + + /** + * Check URL of command opened from command helper + * @param command The command for which to open Read more link + * @param url Command URL for external resourse + */ + async checkURLCommand(command: string, url: string): Promise { + await t.click(this.cliHelperOutputTitles.withExactText(command)); + await t.click(this.readMoreButton); + await t.expect(getPageUrl()).eql(url, 'The opened page'); + } + + /** + * Check URL of command opened from command helper + * @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 { + await t.typeText(this.cliHelperSearch, searchedCommand, { speed: 0.5 }); + //Verify results in the output + const commandsCount = await this.cliHelperOutputTitles.count; + for (let i = 0; i < commandsCount; i++) { + await t.expect(this.cliHelperOutputTitles.nth(i).textContent).eql(listToCompare[i], 'Results in the output contains searched value'); + } + } } diff --git a/tests/e2e/pageObjects/database-overview-page.ts b/tests/e2e/pageObjects/database-overview-page.ts index 737dac465b..5ef96f684d 100644 --- a/tests/e2e/pageObjects/database-overview-page.ts +++ b/tests/e2e/pageObjects/database-overview-page.ts @@ -11,5 +11,10 @@ export class DatabaseOverviewPage { overviewTotalKeys = Selector('[data-test-subj=overview-total-keys]'); overviewTotalMemory = Selector('[data-test-subj=overview-total-memory]'); databaseModules = Selector('[data-testid$=module]'); + overviewTooltipStatTitle = Selector('[data-testid=overview-db-stat-title]'); + //BUTTONS overviewRedisStackLogo = Selector('[data-testid=redis-stack-logo]'); + overviewMoreInfo = Selector('[data-testid=overview-more-info-button]'); + //Panel + overviewTooltip = Selector('[data-testid=overview-more-info-tooltip]'); } diff --git a/tests/e2e/pageObjects/monitor-page.ts b/tests/e2e/pageObjects/monitor-page.ts index ea035eecec..a0de888b9f 100644 --- a/tests/e2e/pageObjects/monitor-page.ts +++ b/tests/e2e/pageObjects/monitor-page.ts @@ -15,6 +15,9 @@ export class MonitorPage { hideMonitor = Selector('[data-testid=hide-monitor]'); closeMonitor = Selector('[data-testid=close-monitor]'); resetProfilerButton = Selector('[data-testid=reset-profiler-btn]'); + saveLogContainer = Selector('[data-testid=save-log-container]'); + saveLogSwitchButton = Selector('[data-testid=save-log-switch]'); + downloadLogButton = Selector('[data-testid=download-log-btn]'); //TEXT ELEMENTS monitorIsStoppedText = Selector('[data-testid=monitor-stopped]'); monitorIsStartedText = Selector('[data-testid=monitor-started]'); @@ -23,6 +26,10 @@ export class MonitorPage { monitorCommandLinePart = Selector('[data-testid=monitor] span'); monitorCommandLineTimestamp = Selector('[data-testid=monitor] span').withText(/[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}/); monitorNoPermissionsMessage = Selector('[data-testid=monitor-error-message]'); + saveLogToolTip = Selector('[data-testid=save-log-tooltip]'); + monitorNotStartedElement = Selector('[data-testid=monitor-not-started]'); + profilerRunningTime = Selector('[data-testid=profiler-running-time]'); + downloadLogPanel = Selector('[data-testid=download-log-panel]'); /** * Check specific command in Monitor @@ -49,6 +56,16 @@ export class MonitorPage { //Check for "info" command that is sent automatically every 5 seconds from BE side await this.checkCommandInMonitorResults('info'); } + /** + * Start monitor with Save log function + */ + async startMonitorWithSaveLog(): Promise { + await t.click(this.expandMonitor); + await t.click(this.saveLogSwitchButton); + await t.click(this.startMonitorButton); + //Check for "info" command that is sent automatically every 5 seconds from BE side + await this.checkCommandInMonitorResults('info'); + } /** * Stop monitor function */ @@ -56,4 +73,10 @@ export class MonitorPage { await t.click(this.runMonitorToggle); await t.expect(this.resetProfilerButton.visible).ok('Reset profiler button appeared'); } + + //Reset profiler + async resetProfiler(): Promise { + await t.click(this.runMonitorToggle); + await t.click(this.resetProfilerButton); + } } diff --git a/tests/e2e/pageObjects/my-redis-databases-page.ts b/tests/e2e/pageObjects/my-redis-databases-page.ts index 317c7923ae..08ee614e8d 100644 --- a/tests/e2e/pageObjects/my-redis-databases-page.ts +++ b/tests/e2e/pageObjects/my-redis-databases-page.ts @@ -15,7 +15,7 @@ export class MyRedisDatabasePage { browserButton = Selector('[data-testid=browser-page-btn]'); myRedisDBButton = Selector('[data-test-subj=home-page-btn]'); deleteDatabaseButton = Selector('[data-testid^=delete-instance-]'); - confirmDeleteButton = Selector('[data-testid^=delete-instance-confirm]'); + confirmDeleteButton = Selector('[data-testid^=delete-instance-]').withExactText('Remove'); toastCloseButton = Selector('[data-test-subj=toastCloseButton]'); deleteButtonInPopover = Selector('#deletePopover button'); confirmDeleteAllDbButton = Selector('[data-testid=delete-selected-dbs]'); diff --git a/tests/e2e/pageObjects/workbench-page.ts b/tests/e2e/pageObjects/workbench-page.ts index 986e03be46..018f4b7a01 100644 --- a/tests/e2e/pageObjects/workbench-page.ts +++ b/tests/e2e/pageObjects/workbench-page.ts @@ -31,6 +31,7 @@ export class WorkbenchPage { paginationButtonPrevious = Selector(this.cssSelectorPaginationButtonPrevious); paginationButtonNext = Selector(this.cssSelectorPaginationButtonNext); preselectList = Selector('[data-testid*=preselect-List]'); + preselectIndexInformation = Selector('[data-testid="preselect-Additional index information"]'); preselectHashCreate = Selector('[data-testid=preselect-Create]'); preselectIndexInfo = Selector('[data-testid*=preselect-Index]'); preselectSearch = Selector('[data-testid=preselect-Search]'); diff --git a/tests/e2e/tests/critical-path/browser/database-overview.e2e.ts b/tests/e2e/tests/critical-path/browser/database-overview.e2e.ts index c6fb22136e..9d17cf6935 100644 --- a/tests/e2e/tests/critical-path/browser/database-overview.e2e.ts +++ b/tests/e2e/tests/critical-path/browser/database-overview.e2e.ts @@ -1,20 +1,19 @@ -import { addNewStandaloneDatabase } from '../../../helpers/database'; -import { rte } from '../../../helpers/constants'; import { Chance } from 'chance'; -import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; +import { addNewStandaloneDatabase, acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; +import { rte } from '../../../helpers/constants'; import { Common } from '../../../helpers/common'; import { - MyRedisDatabasePage, - BrowserPage, - CliPage, - DatabaseOverviewPage, - WorkbenchPage + MyRedisDatabasePage, + BrowserPage, + CliPage, + DatabaseOverviewPage, + WorkbenchPage } from '../../../pageObjects'; import { - commonUrl, - ossStandaloneConfig, - ossStandaloneRedisearch, - ossStandaloneBigConfig + commonUrl, + ossStandaloneConfig, + ossStandaloneRedisearch, + ossStandaloneBigConfig } from '../../../helpers/conf'; const myRedisDatabasePage = new MyRedisDatabasePage(); @@ -34,35 +33,34 @@ let keys2: string[]; fixture `Database overview` .meta({type: 'critical_path'}) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Delete database await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test .meta({ rte: rte.standalone }) - .after(async () => { + .after(async() => { //Delete databases await deleteDatabase(ossStandaloneConfig.databaseName); await deleteDatabase(ossStandaloneRedisearch.databaseName); - }) - ('Verify that user can see the list of Modules updated each time when he connects to the database', async t => { - let firstDatabaseModules = []; - let secondDatabaseModules = []; + })('Verify that user can see the list of Modules updated each time when he connects to the database', async t => { + const firstDatabaseModules = []; + const secondDatabaseModules = []; //Remember modules let countOfModules = await browserPage.modulesButton.count; for(let i = 0; i < countOfModules; i++) { firstDatabaseModules.push(await browserPage.modulesButton.nth(i).getAttribute('data-testid')); } //Verify the list of modules in Browser page - for (let module of firstDatabaseModules) { + for (const module of firstDatabaseModules) { await t.expect(databaseOverviewPage.databaseModules.withAttribute('aria-labelledby', module).exists).ok(`${module} is displayed in the list`); } //Open the Workbench page and verify modules await t.click(myRedisDatabasePage.workbenchButton); - for (let module of firstDatabaseModules) { + for (const module of firstDatabaseModules) { await t.expect(databaseOverviewPage.databaseModules.withAttribute('aria-labelledby', module).exists).ok(`${module} is displayed in the list`); } //Add database with different modules @@ -74,14 +72,13 @@ test secondDatabaseModules.push(await browserPage.modulesButton.nth(i).getAttribute('data-testid')); } //Verify the list of modules - for (let module of secondDatabaseModules) { + for (const module of secondDatabaseModules) { await t.expect(databaseOverviewPage.databaseModules.withAttribute('aria-labelledby', module).exists).ok(`${module} is displayed in the list`); } await t.expect(firstDatabaseModules).notEql(secondDatabaseModules, 'The list of Modules updated'); }); test - .meta({ rte: rte.standalone }) - ('Verify that when user adds or deletes a new key, info in DB header is updated in 5 seconds', async t => { + .meta({ rte: rte.standalone })('Verify that when user adds or deletes a new key, info in DB header is updated in 5 seconds', async t => { keyName = chance.string({ length: 10 }); //Remember the total keys number const totalKeysBeforeAdd = await browserPage.overviewTotalKeys.innerText; @@ -110,8 +107,7 @@ test await cliPage.sendCommandInCli(`DEL ${keys2.join(' ')}`); await deleteDatabase(ossStandaloneConfig.databaseName); await deleteDatabase(ossStandaloneBigConfig.databaseName); - }) - ('Verify that user can see total number of keys rounded in format 100, 1K, 1M, 1B in DB header in Browser page', async t => { + })('Verify that user can see total number of keys rounded in format 100, 1K, 1M, 1B in DB header in Browser page', async t => { //Add 100 keys keys1 = await common.createArrayWithKeyValue(100); let totalKeys = await cliPage.sendCliCommandAndWaitForTotalKeys(`MSET ${keys1.join(' ')}`); @@ -119,7 +115,7 @@ test await t.expect(totalKeys).eql('100', 'Info in DB header after ADD 100 keys'); //Add 1000 keys keys2 = await common.createArrayWithKeyValue(1000); - totalKeys = await cliPage.sendCliCommandAndWaitForTotalKeys(`MSET ${keys2.join(' ')}`);; + totalKeys = await cliPage.sendCliCommandAndWaitForTotalKeys(`MSET ${keys2.join(' ')}`); //Verify that the info on DB header is updated after adds await t.expect(totalKeys).eql('1 K', 'Info in DB header after ADD 1000 keys'); //Add database with more than 1M keys @@ -134,12 +130,11 @@ test }); test .meta({ rte: rte.standalone }) - .after(async () => { + .after(async() => { //Clear and delete database await cliPage.sendCommandInCli(`DEL ${keys.join(' ')}`); await deleteDatabase(ossStandaloneConfig.databaseName); - }) - ('Verify that user can see total memory rounded in format B, KB, MB, GB, TB in DB header in Browser page', async t => { + })('Verify that user can see total memory rounded in format B, KB, MB, GB, TB in DB header in Browser page', async t => { //Add new keys keys = await common.createArrayWithKeyValue(100); await cliPage.sendCommandInCli(`MSET ${keys.join(' ')}`); @@ -154,12 +149,11 @@ test //Go to Workbench page await t.click(myRedisDatabasePage.workbenchButton); }) - .after(async () => { + .after(async() => { //Delete database and index - await workbenchPage.sendCommandInWorkbench(`FT.DROPINDEX idx:schools DD`); + await workbenchPage.sendCommandInWorkbench('FT.DROPINDEX idx:schools DD'); await deleteDatabase(ossStandaloneBigConfig.databaseName); - }) - ('Verify that user can see additional information in Overview: Connected Clients, Commands/Sec, CPU (%) using Standalone DB connection type', async t => { + })('Verify that user can see additional information in Overview: Connected Clients, Commands/Sec, CPU (%) using Standalone DB connection type', async t => { const commandsSecBeforeEdit = await browserPage.overviewCommandsSec.textContent; //Wait 5 second await t.wait(fiveSecondsTimeout); diff --git a/tests/e2e/tests/critical-path/browser/json-key.e2e.ts b/tests/e2e/tests/critical-path/browser/json-key.e2e.ts index d717ee96f3..2ccf68cc5d 100644 --- a/tests/e2e/tests/critical-path/browser/json-key.e2e.ts +++ b/tests/e2e/tests/critical-path/browser/json-key.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { rte } from '../../../helpers/constants'; import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; import { BrowserPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const browserPage = new BrowserPage(); const chance = new Chance(); @@ -14,20 +14,21 @@ const value = '{"name":"xyz"}'; fixture `JSON Key verification` .meta({ type: 'critical_path' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await browserPage.deleteKeyByName(keyName); await deleteDatabase(ossStandaloneConfig.databaseName); }) -test +//skipped due the issue https://redislabs.atlassian.net/browse/RI-2866 +test.skip .meta({ rte: rte.standalone }) ('Verify that user can not add invalid JSON structure inside of created JSON', async t => { keyName = chance.word({ length: 10 }); //Add Json key with json object - await browserPage.addJsonKey(keyName, keyTTL, value); + await browserPage.addJsonKey(keyName, value, keyTTL); //Check the notification message const notofication = await browserPage.getMessageText(); await t.expect(notofication).contains('Key has been added', 'The notification'); diff --git a/tests/e2e/tests/critical-path/browser/large-data.e2e.ts b/tests/e2e/tests/critical-path/browser/large-data.e2e.ts index 62c11f0bb3..1e9e4b991a 100644 --- a/tests/e2e/tests/critical-path/browser/large-data.e2e.ts +++ b/tests/e2e/tests/critical-path/browser/large-data.e2e.ts @@ -1,9 +1,9 @@ +import { Chance } from 'chance'; import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; import { Common } from '../../../helpers/common'; import { rte } from '../../../helpers/constants'; import { BrowserPage, CliPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const browserPage = new BrowserPage(); const cliPage = new CliPage(); @@ -15,17 +15,16 @@ let keyName = chance.word({ length: 10 }); fixture `Cases with large data` .meta({ type: 'critical_path' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await browserPage.deleteKeyByName(keyName); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see relevant information about key size', async t => { + .meta({ rte: rte.standalone })('Verify that user can see relevant information about key size', async t => { keyName = chance.word({ length: 10 }); //Open CLI await t.click(cliPage.cliExpandButton); @@ -37,14 +36,14 @@ test //Remember the values of the key size await browserPage.openKeyDetails(keyName); const keySizeText = await browserPage.keySizeDetails.textContent; - const keySize = keySizeText.split('KB')[0]; + const sizeArray = keySizeText.split(' '); + const keySize = sizeArray[sizeArray.length - 2]; //Verify that user can see relevant information about key size - await t.expect(keySizeText).contains('KB', 'Key size text'); - await t.expect(+keySize).gt(5, 'Key size value'); + await t.expect(keySizeText).contains('KB', 'Key measure'); + await t.expect(+keySize).gt(10, 'Key size value'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see relevant information about key length', async t => { + .meta({ rte: rte.standalone })('Verify that user can see relevant information about key length', async t => { keyName = chance.word({ length: 10 }); //Open CLI await t.click(cliPage.cliExpandButton); @@ -58,5 +57,5 @@ test await browserPage.openKeyDetails(keyName); const keyLength = await browserPage.keyLengthDetails.textContent; //Verify that user can see relevant information about key size - await t.expect(keyLength).eql(`Length (${length})`, 'Key length'); + await t.expect(keyLength).eql(`Length: ${length}`, 'Key length'); }); diff --git a/tests/e2e/tests/critical-path/browser/list-key.e2e.ts b/tests/e2e/tests/critical-path/browser/list-key.e2e.ts index 2f1377a19a..41da9446d6 100644 --- a/tests/e2e/tests/critical-path/browser/list-key.e2e.ts +++ b/tests/e2e/tests/critical-path/browser/list-key.e2e.ts @@ -1,4 +1,5 @@ import { toNumber } from 'lodash'; +import { Chance } from 'chance'; import { rte } from '../../../helpers/constants'; import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; import { BrowserPage, CliPage } from '../../../pageObjects'; @@ -7,7 +8,6 @@ import { ossStandaloneConfig, ossStandaloneV5Config } from '../../../helpers/conf'; -import { Chance } from 'chance'; const browserPage = new BrowserPage(); const cliPage = new CliPage(); @@ -22,17 +22,16 @@ const element3 = '33333listElement33333'; fixture `List Key verification` .meta({ type: 'critical_path' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await browserPage.deleteKeyByName(keyName); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can search List element by index', async t => { + .meta({ rte: rte.standalone })('Verify that user can search List element by index', async t => { keyName = chance.word({ length: 10 }); await browserPage.addListKey(keyName, keyTTL, element); //Add few elements to the List key @@ -46,16 +45,15 @@ test }); test .meta({ rte: rte.standalone }) - .before(async () => { + .before(async() => { // add oss standalone v5 await acceptLicenseTermsAndAddDatabase(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); }) - .after(async () => { + .after(async() => { //Clear and delete database await browserPage.deleteKeyByName(keyName); await deleteDatabase(ossStandaloneV5Config.databaseName); - }) - ('Verify that user can remove only one element for List for Redis v. <6.2', async t => { + })('Verify that user can remove only one element for List for Redis v. <6.2', async t => { keyName = chance.word({ length: 10 }); //Open CLI await t.click(cliPage.cliExpandButton); @@ -65,10 +63,10 @@ test await t.click(cliPage.cliCollapseButton); //Remove element from the key await browserPage.openKeyDetails(keyName); - const lengthBeforeRemove = (await browserPage.keyLengthDetails.textContent).split('(')[1].split(')')[0]; + const lengthBeforeRemove = (await browserPage.keyLengthDetails.textContent).split(': ')[1]; await browserPage.removeListElementFromHeadOld(); //Check that only one element is removed - const lengthAfterRemove = (await browserPage.keyLengthDetails.textContent).split('(')[1].split(')')[0]; + const lengthAfterRemove = (await browserPage.keyLengthDetails.textContent).split(': ')[1]; const removedElements = toNumber(lengthBeforeRemove) - toNumber(lengthAfterRemove); await t.expect(removedElements).eql(1, 'only one element is removed'); }); diff --git a/tests/e2e/tests/critical-path/browser/stream-key-entry-deletion.e2e.ts b/tests/e2e/tests/critical-path/browser/stream-key-entry-deletion.e2e.ts new file mode 100644 index 0000000000..ade40b0724 --- /dev/null +++ b/tests/e2e/tests/critical-path/browser/stream-key-entry-deletion.e2e.ts @@ -0,0 +1,72 @@ +import { Chance } from 'chance'; +import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; +import { rte } from '../../../helpers/constants'; +import { BrowserPage, CliPage } from '../../../pageObjects'; +import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; + +const browserPage = new BrowserPage(); +const cliPage = new CliPage(); +const chance = new Chance(); + +let keyName = chance.word({length: 20}); +const fields = [ + 'Pressure', + 'Humidity', + 'Temperature' +]; +const values = [ + '234', + '78', + '27' +]; + +fixture `Stream key entry deletion` + .meta({ + type: 'critical_path', + rte: rte.standalone + }) + .page(commonUrl) + .beforeEach(async() => { + await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); + }) + .afterEach(async() => { + await browserPage.deleteKeyByName(keyName); + await deleteDatabase(ossStandaloneConfig.databaseName); + }); +test('Verify that the Stream information is refreshed and the deleted entry is removed when user confirm the deletion of an entry', async t => { + keyName = chance.word({length: 20}); + const fieldForDeletion = fields[2]; + //Add new Stream key with 3 fields + for(let i = 0; i < fields.length; i++){ + await cliPage.sendCommandInCli(`XADD ${keyName} * ${fields[i]} ${values[i]}`); + } + //Open key details and remember the Stream information + await browserPage.openKeyDetails(keyName); + await t.click(browserPage.fullScreenModeButton); + await t.expect(browserPage.streamFields.nth(0).textContent).eql(fieldForDeletion, 'The first field entry name'); + const entriesCountBefore = (await browserPage.keyLengthDetails.textContent).split(': ')[1]; + //Delete entry from the Stream + await browserPage.deleteStreamEntry(); + //Check results + const entriesCountAfter = (await browserPage.keyLengthDetails.textContent).split(': ')[1]; + await t.expect(Number(entriesCountBefore) - 1).eql(Number(entriesCountAfter), 'The Entries length is refreshed'); + const fieldsLengthAfter = await browserPage.streamFields.count; + for(let i = fieldsLengthAfter - 1; i <= 0; i--){ + const fieldName = await browserPage.streamFields.nth(i).textContent; + await t.expect(fieldName).notEql(fieldForDeletion, 'The deleted entry is removed from the Stream'); + } + await t.click(browserPage.fullScreenModeButton); +}); +test('Verify that when user delete the last Entry from the Stream the Stream key is not deleted', async t => { + keyName = chance.word({length: 20}); + const emptyStreamMessage = 'There are no Entries in the Stream.'; + //Add new Stream key with 1 field + await cliPage.sendCommandInCli(`XADD ${keyName} * ${fields[0]} ${values[0]}`); + //Open key details and delete entry from the Stream + await browserPage.openKeyDetails(keyName); + await browserPage.deleteStreamEntry(); + //Check results + await t.expect(browserPage.streamEntriesContainer.textContent).contains(emptyStreamMessage, 'The message after deletion of the last Entry from the Stream'); + await browserPage.searchByKeyName(keyName); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyName)).ok('The Stream key is not deleted'); +}); diff --git a/tests/e2e/tests/critical-path/browser/stream-key.e2e.ts b/tests/e2e/tests/critical-path/browser/stream-key.e2e.ts new file mode 100644 index 0000000000..07aa093181 --- /dev/null +++ b/tests/e2e/tests/critical-path/browser/stream-key.e2e.ts @@ -0,0 +1,137 @@ +import { Chance } from 'chance'; +import { Selector } from 'testcafe'; +import { toNumber, toString } from 'lodash'; +import { rte } from '../../../helpers/constants'; +import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; +import { BrowserPage } from '../../../pageObjects'; +import { + commonUrl, + ossStandaloneConfig +} from '../../../helpers/conf'; + +const browserPage = new BrowserPage(); +const chance = new Chance(); + +let keyName = chance.word({ length: 20 }); +const keyField = chance.word({ length: 20 }); +const keyValue = chance.word({ length: 20 }); + +fixture `Stream Key` + .meta({ type: 'critical_path', rte: rte.standalone }) + .page(commonUrl) + .beforeEach(async() => { + await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); + }) + .afterEach(async() => { + //Clear and delete database + await browserPage.deleteKeyByName(keyName); + await deleteDatabase(ossStandaloneConfig.databaseName); + }); +test('Verify that user can create Stream key via Add New Key form', async t => { + keyName = chance.word({ length: 20 }); + // Add New Stream Key + await browserPage.addStreamKey(keyName, keyField, keyValue); + // Verify that user can see Stream details opened after key creation + await t.expect(browserPage.keyNameFormDetails.withExactText(keyName).visible).ok('Stream Key Name'); + // Verify that user can see newly added Stream key in key list clicking on keys refresh button + await t.click(browserPage.refreshKeysButton); + const isKeyIsDisplayedInTheList = await browserPage.isKeyIsDisplayedInTheList(keyName); + await t.expect(isKeyIsDisplayedInTheList).ok('Stream is added'); +}); +test('Verify that user can add several fields and values during Stream key creation', async t => { + const keyName = chance.word({ length: 20 }); + // Create an array with different data types for Stream fields + const streamData = {'string': chance.word({ length: 20 }), 'array': `[${chance.word({ length: 20 })}, ${chance.integer()}]`, 'integer': `${chance.integer()}`, 'json': '{\'test\': \'test\'}', 'null': 'null', 'boolean': 'true'}; + const scrollSelector = Selector('.eui-yScroll').nth(-1); + // Open Add New Stream Key Form + await browserPage.commonAddNewKey(keyName); + await t.click(browserPage.streamOption); + // Verify that user can see Entity ID filled by * by default on add Stream key form + await t.expect(browserPage.streamEntryId.withAttribute('value', '*').visible).ok('Preselected Stream Entity ID field'); + // Verify that user can specify valid custom value for Entry ID + await t.typeText(browserPage.streamEntryId, '0-1', {replace: true}); + // Filled fields and value by different data types + for (let i = 0; i < Object.keys(streamData).length; i++) { + await t.typeText(browserPage.streamField.nth(-1), Object.keys(streamData)[i]); + await t.typeText(browserPage.streamValue.nth(-1), Object.values(streamData)[i]); + await t.scroll(scrollSelector, 'bottom'); + await t.expect(browserPage.streamField.count).eql(i + 1, 'Number of added fields'); + if (i < Object.keys(streamData).length - 1) { + await t.click(browserPage.addStreamRow); + } + } + await t.expect(browserPage.addKeyButton.withAttribute('disabled').exists).notOk('Clickable Add Key button'); + await t.click(browserPage.addKeyButton); + await t.expect(browserPage.keyNameFormDetails.withExactText(keyName).visible).ok('Stream Key Name'); +}); +test('Verify that user can add new Stream Entry for Stream data type key which has an Entry ID, Field and Value', async t => { + keyName = chance.word({ length: 20 }); + // Add New Stream Key + await browserPage.addStreamKey(keyName, keyField, keyValue); + // Verify that when user adds a new Entry with not existed Field name, a new Field is added to the Stream + const paramsBeforeEntryAdding = await browserPage.getStreamRowColumnNumber(); + await browserPage.addEntryToStream(chance.word({ length: 20 }), chance.word({ length: 20 })); + // Compare that after adding new entry, new column and row were added + const paramsAfterEntryAdding = await browserPage.getStreamRowColumnNumber(); + await t.expect(paramsAfterEntryAdding[0]).eql(toString(toNumber(paramsBeforeEntryAdding[0]) + 1), 'Increased number of columns after adding'); + await t.expect(paramsAfterEntryAdding[1]).eql(toString(toNumber(paramsBeforeEntryAdding[1]) + 1), 'Increased number of rows after adding'); + // Verify that when user adds a new Entry with already existed Field name, a new Field is available as column in the Stream table + const paramsBeforeExistedFieldAdding = await browserPage.getStreamRowColumnNumber(); + await browserPage.addEntryToStream(keyField, chance.word({ length: 20 })); + const paramsAfterExistedFieldAdding = await browserPage.getStreamRowColumnNumber(); + await t.expect(paramsAfterExistedFieldAdding[0]).eql(paramsBeforeExistedFieldAdding[0], 'The same number of columns after adding'); + await t.expect(paramsAfterExistedFieldAdding[1]).eql(toString(toNumber(paramsBeforeExistedFieldAdding[1]) + 1), 'Increased number of rows after adding'); +}); +test('Verify that during new entry adding to existing Stream, user can clear the value and the row itself', async t => { + keyName = chance.word({ length: 20 }); + // Generate data for stream + const fields = [keyField, chance.word({ length: 20 })]; + const values = [keyValue, chance.word({ length: 20 })]; + // Add New Stream Key + await browserPage.addStreamKey(keyName, keyField, keyValue); + await t.click(browserPage.addNewStreamEntry); + await browserPage.fulfillSeveralStreamFields(fields, values); + // Check number of rows + const fieldsNumberBeforeDeletion = await browserPage.streamField.count; + // Click on delete field for the last entity + await t.click(browserPage.clearStreamEntryInputs.nth(-1)); + const fieldsNumberAfterDeletion = await browserPage.streamField.count; + await t.expect(fieldsNumberAfterDeletion).lt(fieldsNumberBeforeDeletion, 'Number of fields after deletion'); + // Validate that the last field and value were fulfilled + await t.expect(browserPage.streamField.withAttribute('value', keyField).exists).ok('Filled input for field'); + await t.expect(browserPage.streamValue.withAttribute('value', keyValue).exists).ok('Filled input for value'); + // Click on clear button + await t.hover(browserPage.streamValue); + await t.click(browserPage.clearStreamEntryInputs); + // Validate that data was cleared + await t.expect(browserPage.streamField.withAttribute('value', keyField).exists).notOk('Cleared input for field'); + await t.expect(browserPage.streamValue.withAttribute('value', keyValue).exists).notOk('Cleared input for value'); + // Validate that the form is still displayed + await t.expect(browserPage.streamField.count).eql(fieldsNumberAfterDeletion, 'Number of fields after deletion'); +}); +test('Verify that user can add several fields and values to the existing Stream Key', async t => { + keyName = chance.word({ length: 20 }); + // Generate field value data + const entryQuantity = 10; + const fields: string[] = []; + const values: string[] = []; + for (let i = 0; i < entryQuantity; i++) { + const randomGeneratorValue = chance.integer({ min: 1, max: 50 }); + fields.push(chance.word({ length: randomGeneratorValue })); + values.push(chance.word({ length: randomGeneratorValue })); + } + // Add New Stream Key + await browserPage.addStreamKey(keyName, keyField, keyValue); + await t.click(browserPage.addNewStreamEntry); + // Filled Stream by new several Fields + await browserPage.fulfillSeveralStreamFields(fields, values); + await t.click(browserPage.saveElementButton); + // Check that all data is saved in Stream + for (let i = 0; i < fields.length; i++) { + await t.expect(browserPage.streamEntriesContainer.find('span').withExactText(fields[i]).exists).ok('Added Field'); + await t.expect(browserPage.streamFieldsValues.find('span').withExactText(values[i]).exists).ok('Added Value'); + } + // Check Stream length + const streamLength = await browserPage.getKeyLength(); + await t.expect(streamLength).eql('2', 'Stream length after adding new entry'); +}); diff --git a/tests/e2e/tests/critical-path/cli/cli-command-helper.e2e.ts b/tests/e2e/tests/critical-path/cli/cli-command-helper.e2e.ts index 26c5ffec62..77519860a3 100644 --- a/tests/e2e/tests/critical-path/cli/cli-command-helper.e2e.ts +++ b/tests/e2e/tests/critical-path/cli/cli-command-helper.e2e.ts @@ -1,9 +1,7 @@ -import { rte } from '../../../helpers/constants'; +import {env, rte} from '../../../helpers/constants'; import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; import { CliPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { ClientFunction } from 'testcafe'; -import { lowerCase } from 'lodash'; const cliPage = new CliPage(); @@ -12,21 +10,19 @@ const COMMAND_APPEND = 'APPEND'; const COMMAND_GROUP_SET = 'Set'; const COMMAND_GROUP_TIMESERIES = 'TimeSeries'; const COMMAND_GROUP_GRAPH = 'Graph'; -const getPageUrl = ClientFunction(() => window.location.href); fixture `CLI Command helper` .meta({ type: 'critical_path' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Delete database await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see relevant search results in Command Helper per every entered symbol', async t => { + .meta({ rte: rte.standalone })('Verify that user can see relevant search results in Command Helper per every entered symbol', async t => { //Open Command Helper await t.click(cliPage.expandCommandHelperButton); //Start search from 1 symbol @@ -41,8 +37,7 @@ test await t.expect(countCommandsOfOneLetterSearch).gt(countCommandsOfTwoLettersSearch, 'Count of commands with 1 letter more than 2'); }); test - .meta({ rte: rte.standalone }) - ('Verify that when user clears the input in the Search of CLI Helper (via x icon), he can see the default screen with proper the text', async t => { + .meta({ rte: rte.standalone })('Verify that when user clears the input in the Search of CLI Helper (via x icon), he can see the default screen with proper the text', async t => { //Open Command Helper await t.click(cliPage.expandCommandHelperButton); //Verify default text @@ -57,8 +52,7 @@ test await t.expect(cliPage.cliHelperText.textContent).eql(defaultHelperText, 'Default text for CLI Helper is shown'); }); test - .meta({ rte: rte.standalone }) - ('Verify that when user enters command in CLI, Helper displays additional info about the command', async t => { + .meta({ rte: rte.standalone })('Verify that when user enters command in CLI, Helper displays additional info about the command', async t => { //Open CLI and Helper await t.click(cliPage.cliExpandButton); await t.click(cliPage.expandCommandHelperButton); @@ -70,8 +64,7 @@ test await t.expect(cliPage.cliHelperSummary.innerText).contains('Append a value to a key', 'Command summary'); }); test - .meta({ rte: rte.standalone }) - ('Verify that Command helper cleared when user runs the command in CLI', async t => { + .meta({ rte: rte.standalone })('Verify that Command helper cleared when user runs the command in CLI', async t => { const searchText = 'sa'; //Open CLI and Helper await t.click(cliPage.cliExpandButton); @@ -87,8 +80,7 @@ test await t.expect(cliPage.cliHelperSearch.value).eql('', 'Search was cleared'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can unselect the command filtered to remove filters', async t => { + .meta({ rte: rte.standalone })('Verify that user can unselect the command filtered to remove filters', async t => { //Open Command Helper await t.click(cliPage.expandCommandHelperButton); //Select one command from list @@ -102,8 +94,7 @@ test await t.expect(cliPage.cliHelperText.textContent).eql(defaultHelperText, 'Default text for CLI Helper is shown'); }); test - .meta({ rte: rte.standalone }) - ('Verify that when user has used search and apply filters, search results include only commands from the filter group applied', async t => { + .meta({ rte: rte.standalone })('Verify that when user has used search and apply filters, search results include only commands from the filter group applied', async t => { const searchText = 'sa'; //Open Command Helper await t.click(cliPage.expandCommandHelperButton); @@ -117,12 +108,11 @@ test await t.expect(cliPage.cliHelperOutputTitles.withText('SADD').exists).ok('Proper command was found'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can type TS. in Command helper and see commands from RedisTimeSeries commands.json', async t => { + .meta({ env: env.web, rte: rte.standalone })('Verify that user can type TS. in Command helper and see commands from RedisTimeSeries commands.json', async t => { const commandForSearch = 'TS.'; //Open Command Helper await t.click(cliPage.expandCommandHelperButton); - //Select group from list and remeber commands + //Select group from list and remember commands await cliPage.selectFilterGroupType(COMMAND_GROUP_TIMESERIES); const commandsFilterCount = await cliPage.cliHelperOutputTitles.count; const timeSeriesCommands = []; @@ -131,27 +121,19 @@ test } //Unselect group from list await cliPage.selectFilterGroupType(COMMAND_GROUP_TIMESERIES); - //Search per command - await t.typeText(cliPage.cliHelperSearch, commandForSearch); - //Verify results in the output - const commandsCount = await cliPage.cliHelperOutputTitles.count; - for(let i = 0; i < commandsCount; i++){ - await t.expect(cliPage.cliHelperOutputTitles.nth(i).textContent).eql(timeSeriesCommands[i], 'Results in the output contains searched value'); - } - //Check first command documentation url - await t.click(cliPage.cliHelperOutputTitles.withExactText(timeSeriesCommands[0])); - await t.click(cliPage.readMoreButton); - await t.expect(getPageUrl()).eql(`https://redis.io/commands/${timeSeriesCommands[0].toLowerCase()}/`, 'The opened page'); + //Search per part of command and check all opened commands + await cliPage.checkSearchedCommandInCommandHelper(commandForSearch, timeSeriesCommands); + //Check the first command documentation url + await cliPage.checkURLCommand(timeSeriesCommands[0], `https://redis.io/commands/${timeSeriesCommands[0].toLowerCase()}/`); await t.switchToParentWindow(); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can type GRAPH. in Command helper and see auto-suggestions from RedisGraph commands.json', async t => { + .meta({ env: env.web, rte: rte.standalone })('Verify that user can type GRAPH. in Command helper and see auto-suggestions from RedisGraph commands.json', async t => { const commandForSearch = 'GRAPH.'; const externalPageLink = 'https://redis.io/commands/graph.config-get/'; //Open Command Helper await t.click(cliPage.expandCommandHelperButton); - //Select group from list and remeber commands + //Select group from list and remember commands await cliPage.selectFilterGroupType(COMMAND_GROUP_GRAPH); const commandsFilterCount = await cliPage.cliHelperOutputTitles.count; const graphCommands = []; @@ -160,16 +142,9 @@ test } //Unselect group from list await cliPage.selectFilterGroupType(COMMAND_GROUP_GRAPH); - //Search per command - await t.typeText(cliPage.cliHelperSearch, commandForSearch); - //Verify results in the output - const commandsCount = await cliPage.cliHelperOutputTitles.count; - for(let i = 0; i < commandsCount; i++){ - await t.expect(cliPage.cliHelperOutputTitles.nth(i).textContent).eql(graphCommands[i], 'Results in the output contains searched value'); - } - //Check first command documentation url - await t.click(cliPage.cliHelperOutputTitles.withExactText(graphCommands[0])); - await t.click(cliPage.readMoreButton); - await t.expect(getPageUrl()).eql(externalPageLink, 'The opened page'); + //Search per part of command and check all opened commands + await cliPage.checkSearchedCommandInCommandHelper(commandForSearch, graphCommands); + //Check the first command documentation url + await cliPage.checkURLCommand(graphCommands[0], externalPageLink); await t.switchToParentWindow(); }); diff --git a/tests/e2e/tests/critical-path/database/connecting-to-the-db.e2e.ts b/tests/e2e/tests/critical-path/database/connecting-to-the-db.e2e.ts index 43a15795c8..da5719bc6f 100644 --- a/tests/e2e/tests/critical-path/database/connecting-to-the-db.e2e.ts +++ b/tests/e2e/tests/critical-path/database/connecting-to-the-db.e2e.ts @@ -10,15 +10,14 @@ fixture `Connecting to the databases verifications` .page(commonUrl) .beforeEach(async() => { await acceptLicenseTerms(); - }) + }); test - .meta({ rte: rte.none }) - ('Verify that user can see error message if he can not connect to added Database', async t => { + .meta({ rte: rte.none })('Verify that user can see error message if he can not connect to added Database', async t => { //Fill the add database form await addRedisDatabasePage.addRedisDataBase(invalidOssStandaloneConfig); //Click for saving await t.click(addRedisDatabasePage.addRedisDatabaseButton); //Verify that the database is not in the list - await t.expect(addRedisDatabasePage.errorMessage.textContent).contains('Error', 'Error message displaying', { timeout: 60000 }); - await t.expect(addRedisDatabasePage.errorMessage.textContent).contains(`Could not connect to ${invalidOssStandaloneConfig.host}:${invalidOssStandaloneConfig.port}, please check the connection details.`, 'Error message displaying', { timeout: 60000 }); + await t.expect(addRedisDatabasePage.errorMessage.textContent).contains('Error', 'Error message displaying', { timeout: 10000 }); + await t.expect(addRedisDatabasePage.errorMessage.textContent).contains(`Could not connect to ${invalidOssStandaloneConfig.host}:${invalidOssStandaloneConfig.port}, please check the connection details.`, 'Error message displaying', { timeout: 10000 }); }); diff --git a/tests/e2e/tests/critical-path/database/logical-databases.e2e.ts b/tests/e2e/tests/critical-path/database/logical-databases.e2e.ts index 477adbe814..16e4216dbb 100644 --- a/tests/e2e/tests/critical-path/database/logical-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/logical-databases.e2e.ts @@ -16,10 +16,9 @@ fixture `Logical databases` .afterEach(async() => { //Delete database await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can add DB with logical index via host and port from Add DB manually form', async t => { + .meta({ rte: rte.standalone })('Verify that user can add DB with logical index via host and port from Add DB manually form', async t => { const index = '0'; await addRedisDatabasePage.addRedisDataBase(ossStandaloneConfig); //Enter logical index @@ -35,8 +34,7 @@ test await t.expect(myRedisDatabasePage.dbNameList.withExactText(ossStandaloneConfig.databaseName).exists).ok('The existence of the database', { timeout: 10000 }); }); test - .meta({ rte: rte.standalone }) - ('Verify that if user adds DB with logical DB >0, DB name contains postfix "space+[{database index}]"', async t => { + .meta({ rte: rte.standalone })('Verify that if user adds DB with logical DB > 0, DB name contains postfix "space+[{database index}]"', async t => { const index = '10'; await addRedisDatabasePage.addRedisDataBase(ossStandaloneConfig); //Enter logical index @@ -45,5 +43,5 @@ test //Click for saving await t.click(addRedisDatabasePage.addRedisDatabaseButton); //Verify that the database name contains postfix - await t.expect(myRedisDatabasePage.dbNameList.textContent).eql(`${ossStandaloneConfig.databaseName} [${index}]`, 'The postfix is added to the database name', { timeout: 60000 }); + await t.expect(myRedisDatabasePage.dbNameList.textContent).eql(`${ossStandaloneConfig.databaseName} [${index}]`, 'The postfix is added to the database name', { timeout: 10000 }); }); diff --git a/tests/e2e/tests/critical-path/database/modules.e2e.ts b/tests/e2e/tests/critical-path/database/modules.e2e.ts index d86c8e9da9..f0cdbc597b 100644 --- a/tests/e2e/tests/critical-path/database/modules.e2e.ts +++ b/tests/e2e/tests/critical-path/database/modules.e2e.ts @@ -1,17 +1,18 @@ +import { Selector } from 'testcafe'; import { rte, env } from '../../../helpers/constants'; import { acceptLicenseTerms, addNewStandaloneDatabase, deleteDatabase } from '../../../helpers/database'; -import { MyRedisDatabasePage } from '../../../pageObjects'; -import {commonUrl, ossStandaloneRedisearch} from '../../../helpers/conf'; +import { MyRedisDatabasePage, DatabaseOverviewPage } from '../../../pageObjects'; +import { commonUrl, ossStandaloneRedisearch } from '../../../helpers/conf'; const myRedisDatabasePage = new MyRedisDatabasePage(); +const databaseOverviewPage = new DatabaseOverviewPage(); + const moduleNameList = ['RediSearch', 'RedisJSON', 'RedisGraph', 'RedisTimeSeries', 'RedisBloom', 'RedisGears', 'RedisAI']; -const moduleList = [myRedisDatabasePage.moduleSearchIcon, myRedisDatabasePage.moduleJSONIcon, myRedisDatabasePage.moduleGraphIcon, - myRedisDatabasePage.moduleTimeseriesIcon, myRedisDatabasePage.moduleBloomIcon, myRedisDatabasePage.moduleGearsIcon, - myRedisDatabasePage.moduleAIIcon]; +const moduleList = [myRedisDatabasePage.moduleSearchIcon, myRedisDatabasePage.moduleJSONIcon, myRedisDatabasePage.moduleGraphIcon, myRedisDatabasePage.moduleTimeseriesIcon, myRedisDatabasePage.moduleBloomIcon, myRedisDatabasePage.moduleGearsIcon, myRedisDatabasePage.moduleAIIcon]; fixture `Database modules` .meta({ type: 'critical_path' }) @@ -23,10 +24,9 @@ fixture `Database modules` .afterEach(async() => { //Delete database await deleteDatabase(ossStandaloneRedisearch.databaseName); - }) + }); test - .meta({ rte: rte.standalone, env: env.web }) - ('Verify that user can see DB modules on DB list page for Standalone DB', async t => { + .meta({ rte: rte.standalone, env: env.web })('Verify that user can see DB modules on DB list page for Standalone DB', async t => { //Check module column on DB list page await t.expect(myRedisDatabasePage.moduleColumn.exists).ok('Module column'); //Verify that user can see the following sorting order: Search, JSON, Graph, TimeSeries, Bloom, Gears, AI for modules @@ -49,8 +49,7 @@ test await myRedisDatabasePage.checkModulesInTooltip(moduleNameList); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see full module list in the Edit mode', async t => { + .meta({ rte: rte.standalone })('Verify that user can see full module list in the Edit mode', async t => { //Verify that module column is displayed await t.expect(myRedisDatabasePage.moduleColumn.visible).ok('Module column'); //Open Edit mode @@ -60,3 +59,20 @@ test //Verify modules in Edit mode await myRedisDatabasePage.checkModulesOnPage(moduleList); }); +test + .meta({ rte: rte.standalone })('Verify that user can see icons in DB header for RediSearch, RedisGraph, RedisJSON, RedisBloom, RedisTimeSeries, RedisGears, RedisAI default modules', async t => { + // Connect to DB + await myRedisDatabasePage.clickOnDBByName(ossStandaloneRedisearch.databaseName); + // Check all available modules in overview + const moduleIcons = await Selector('div').find('[data-testid^=Redi]'); + const numberOfIcons = await moduleIcons.count; + for (let i = 0; i < numberOfIcons; i++) { + const moduleName = await moduleIcons.nth(i).getAttribute('data-testid'); + await t.expect(moduleName).eql(await moduleList[i].getAttribute('data-testid'), 'Correct icon'); + } + // Verify that if DB has more than 6 modules loaded, user can click on three dots and see other modules in the tooltip + await t.click(databaseOverviewPage.overviewMoreInfo); + for (let j = numberOfIcons; j < moduleNameList.length; j++) { + await t.expect(databaseOverviewPage.overviewTooltip.withText(moduleNameList[j]).visible).ok('Tooltip module'); + } + }); diff --git a/tests/e2e/tests/critical-path/monitor/save-commands.e2e.ts b/tests/e2e/tests/critical-path/monitor/save-commands.e2e.ts new file mode 100644 index 0000000000..fe086c3bda --- /dev/null +++ b/tests/e2e/tests/critical-path/monitor/save-commands.e2e.ts @@ -0,0 +1,111 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import {acceptLicenseTermsAndAddDatabase, deleteDatabase} from '../../../helpers/database'; +import { MonitorPage, CliPage } from '../../../pageObjects'; +import { + commonUrl, + ossStandaloneConfig +} from '../../../helpers/conf'; +import { rte } from '../../../helpers/constants'; + +const monitorPage = new MonitorPage(); +const cliPage = new CliPage(); +const tempDir = os.tmpdir(); +const downloadsDir = `C:*****\\Downloads`; + +fixture `Save commands` + .meta({ type: 'regression' }) + .page(commonUrl) + .beforeEach(async() => { + await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); + }) + .afterEach(async() => { + //Delete database + await deleteDatabase(ossStandaloneConfig.databaseName); + }); +test + .meta({ rte: rte.standalone })('Verify that user can see a tooltip and toggle that allows to save Profiler log or not in the Profiler', async t => { + const toolTip = [ + 'Allows you to download the generated log file after pausing the Profiler', + 'Profiler log is saved to a file on your local machine with no size limitation. The temporary log file will be automatically rewritten when the Profiler is reset.' + ]; + await t.click(monitorPage.expandMonitor); + //Check the toggle and Tooltip for Save log + await t.expect(monitorPage.saveLogSwitchButton.visible).ok('The toggle that allows to save Profiler log is displayed'); + await t.hover(monitorPage.saveLogSwitchButton); + for(const message of toolTip){ + await t.expect(monitorPage.saveLogToolTip.textContent).contains(message, 'The toolTip for save log in Profiler is displayed'); + } + //Check toggle state + await t.expect(monitorPage.saveLogSwitchButton.getAttribute('aria-checked')).eql('false', 'The toggle state is OFF when opens Profiler'); + }); +test + .meta({ rte: rte.standalone })('Verify that user can see that toggle is not displayed when Profiler is started', async t => { + //Start Monitor without save logs + await monitorPage.startMonitor(); + //Check the toggle + await t.expect(monitorPage.saveLogSwitchButton.visible).notOk('The toggle is not displayed when Profiler is started'); + //Restart Monitor with Save logs + await monitorPage.stopMonitor(); + await t.click(monitorPage.resetProfilerButton); + await t.click(monitorPage.saveLogSwitchButton); + await t.click(monitorPage.startMonitorButton); + //Check the toggle + await t.expect(monitorPage.saveLogSwitchButton.visible).notOk('The toggle is not displayed when Profiler is started'); + }); +//skipped due the temp file is not created after the start of profiler +test.skip + .meta({ rte: rte.standalone })('Verify that when user switch toggle to ON and started the Profiler, temporary Log file Created and recording', async t => { + const cli_command = 'command'; + //Remember the number of files in Temp + const numberOfTempFiles = fs.readdirSync(tempDir).length; + //Start Monitor with Save logs + await monitorPage.startMonitorWithSaveLog(); + //Send command in CLI + await cliPage.getSuccessCommandResultFromCli(cli_command); + await monitorPage.checkCommandInMonitorResults(cli_command); + //Verify that temporary Log file Created + await t.expect(numberOfTempFiles).gt(fs.readdirSync(tempDir).length, 'The temporary Log file is created'); + }); +test + .meta({ rte: rte.standalone })('Verify that when user switch toggle to OFF and started the Profiler, temporary Log file is not Created and recording', async t => { + //Remember the number of files in Temp + const numberOfTempFiles = fs.readdirSync(tempDir).length; + //Start Monitor with Save logs + await monitorPage.startMonitor(); + //Verify that temporary Log file is not created + await t.expect(numberOfTempFiles).lte(fs.readdirSync(tempDir).length, 'The temporary Log file is not created'); + }); +test + .meta({ rte: rte.standalone })('Verify the Profiler Button panel when toggle was switched to ON and user pauses/resumes the Profiler', async t => { + //Start Monitor with Save logs + await monitorPage.startMonitorWithSaveLog(); + //Pause the Profiler + await t.click(monitorPage.runMonitorToggle); + //Check the panel + await t.expect(monitorPage.downloadLogPanel.visible).ok('The download log panel appears'); + await t.expect(monitorPage.resetProfilerButton.visible).ok('The Reset Profiler button visibility'); + await t.expect(monitorPage.downloadLogButton.visible).ok('The Download button visibility'); + }); +//skipped due the error in path +test.skip + .meta({ rte: rte.standalone })('Verify that when user see the toggle is OFF - Profiler logs are not being saved', async t => { + //Remember the number of files in Temp + const numberOfDownloadFiles = fs.readdirSync(downloadsDir).length; + //Start Monitor without Save logs + await monitorPage.startMonitor(); + //Check the download files + await t.expect(numberOfDownloadFiles).eql(fs.readdirSync(downloadsDir).length, 'The Profiler logs are not being saved'); + }); +//skipped due the error in path +test.skip + .meta({ rte: rte.standalone })('Verify that when user see the toggle is ON - Profiler logs are being saved', async t => { + //Remember the number of files in Temp + const numberOfDownloadFiles = fs.readdirSync(downloadsDir).length; + //Start Monitor with Save logs + await monitorPage.startMonitorWithSaveLog(); + //Download logs and check result + await monitorPage.stopMonitor(); + await t.click(monitorPage.downloadLogButton); + await t.expect(numberOfDownloadFiles).gt(fs.readdirSync(downloadsDir).length, 'The Profiler logs are being saved'); + }); diff --git a/tests/e2e/tests/critical-path/tree-view/tree-view.e2e.ts b/tests/e2e/tests/critical-path/tree-view/tree-view.e2e.ts index 470c0457d3..705003ec6e 100644 --- a/tests/e2e/tests/critical-path/tree-view/tree-view.e2e.ts +++ b/tests/e2e/tests/critical-path/tree-view/tree-view.e2e.ts @@ -21,34 +21,31 @@ fixture `Tree view verifications` .afterEach(async() => { //Delete database await deleteDatabase(ossStandaloneBigConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that when user opens the application he can see that Tree View is disabled by default(Browser is selected by default)', async t => { + .meta({ rte: rte.standalone })('Verify that when user opens the application he can see that Tree View is disabled by default(Browser is selected by default)', async t => { //Verify that Browser view is selected by default and Tree view is disabled await t.expect(browserPage.browserViewButton.getStyleProperty('background-color')).eql('rgb(41, 47, 71)', 'The Browser is selected by default'); - await t.expect(browserPage.treeViewArea.visible).notOk('The tree view is not displayed', { timeout: 20000 }); + await t.expect(browserPage.treeViewArea.visible).notOk('The tree view is not displayed', { timeout: 10000 }); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see that "Tree view" mode is enabled state is saved when refreshes the page', async t => { + .meta({ rte: rte.standalone })('Verify that user can see that "Tree view" mode is enabled state is saved when refreshes the page', async t => { await t.click(browserPage.treeViewButton); await t.eval(() => location.reload()); //Verify that "Tree view" mode enabled state is saved await t.expect(browserPage.treeViewArea.visible).ok('The tree view is displayed'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see DB is automatically scanned by 10K keys in the background, user can see the number of keys scanned and use the "Scan More" button to search per another 10000 keys', async t => { - let scannedValue = 10; + .meta({ rte: rte.standalone })('Verify that user can see DB is automatically scanned by 10K keys in the background, user can see the number of keys scanned and use the "Scan More" button to search per another 10000 keys', async t => { await t.click(browserPage.treeViewButton); - await t.expect(browserPage.scannedValue.visible).ok('The database scanned value is displayed', { timeout: 60000 }); - await t.expect(browserPage.scannedValue.textContent).eql(`${scannedValue} 000`, 'The database is automatically scanned by 10K keys'); //Verify that user can use the "Scan More" button to search per another 10000 keys - for (let i = 0; i < 10; i++){ - scannedValue = scannedValue + 10; + for (let i = 10; i < 100; i += 10){ + // scannedValue = scannedValue + 10; + await t.expect(browserPage.progressKeyList.exists).notOk('Progress Bar', { timeout: 30000 }); + const scannedValueText = await browserPage.scannedValue.textContent; + const regExp = new RegExp(`${i} 00` + '.'); + await t.expect(scannedValueText).match(regExp, `The database is automatically scanned by ${i} 000 keys`); await t.click(browserPage.scanMoreButton); - await t.expect(browserPage.scannedValue.textContent).eql(`${scannedValue} 000`, `The database is automatically scanned by ${scannedValue} 000 keys`, { timeout: 60000 }); } }); test @@ -57,15 +54,14 @@ test await browserPage.deleteKeyByName(keyNameFilter); await deleteDatabase(ossStandaloneBigConfig.databaseName); }) - .meta({ rte: rte.standalone }) - ('Verify that when user enables filtering by key name he can see only folder with appropriate keys are displayed and the number of keys and percentage is recalculated', async t => { + .meta({ rte: rte.standalone })('Verify that when user enables filtering by key name he can see only folder with appropriate keys are displayed and the number of keys and percentage is recalculated', async t => { await browserPage.addHashKey(keyNameFilter); await t.click(browserPage.treeViewButton); const numberOfKeys = await browserPage.treeViewKeysNumber.textContent; const percentage = await browserPage.treeViewPercentage.textContent; //Set filter by key name await browserPage.searchByKeyName(keyNameFilter); - await t.expect(browserPage.treeViewKeysItem.visible).ok('The key appears after the filtering', { timeout: 60000 }); + await t.expect(browserPage.treeViewKeysItem.visible).ok('The key appears after the filtering', { timeout: 10000 }); await t.click(browserPage.treeViewKeysItem); //Verify the results await t.expect(browserPage.treeViewKeysNumber.textContent).notEql(numberOfKeys, 'The number of keys is recalculated'); @@ -73,8 +69,7 @@ test await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyNameFilter)).ok('The appropriate keys are displayed'); }); test - .meta({ rte: rte.standalone }) - ('Verify that when user switched from Tree View to Browser and goes back state of filer by key name/key type is saved', async t => { + .meta({ rte: rte.standalone })('Verify that when user switched from Tree View to Browser and goes back state of filer by key name/key type is saved', async t => { const keyName = 'user*'; await t.click(browserPage.treeViewButton); await browserPage.searchByKeyName(keyName); diff --git a/tests/e2e/tests/critical-path/workbench/command-results.e2e.ts b/tests/e2e/tests/critical-path/workbench/command-results.e2e.ts index f4d67194eb..9847f5f022 100644 --- a/tests/e2e/tests/critical-path/workbench/command-results.e2e.ts +++ b/tests/e2e/tests/critical-path/workbench/command-results.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { env, rte } from '../../../helpers/constants'; import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; import { MyRedisDatabasePage, WorkbenchPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); @@ -25,10 +25,9 @@ fixture `Command results at Workbench` await t.switchToMainWindow(); await workbenchPage.sendCommandInWorkbench(`FT.DROPINDEX ${indexName} DD`); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see re-run icon near the already executed command and re-execute the command by clicking on the icon in Workbench page', async t => { + .meta({ rte: rte.standalone })('Verify that user can see re-run icon near the already executed command and re-execute the command by clicking on the icon in Workbench page', async t => { //Send commands await workbenchPage.sendCommandInWorkbench(commandForSend1); await workbenchPage.sendCommandInWorkbench(commandForSend2); @@ -41,8 +40,7 @@ test await t.expect(workbenchPage.queryCardCommand.textContent).eql(commandForSend1, 'The command is re-executed'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see expanded result after command re-run at the top of results table in Workbench', async t => { + .meta({ rte: rte.standalone })('Verify that user can see expanded result after command re-run at the top of results table in Workbench', async t => { //Send commands await workbenchPage.sendCommandInWorkbench(commandForSend1); await workbenchPage.sendCommandInWorkbench(commandForSend2); @@ -55,8 +53,7 @@ test await t.expect(workbenchPage.queryCardCommand.nth(0).textContent).eql(commandForSend1, 'The re-executed command is at the top of results table'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can delete command with result from table with results in Workbench', async t => { + .meta({ rte: rte.standalone })('Verify that user can delete command with result from table with results in Workbench', async t => { //Send command await workbenchPage.sendCommandInWorkbench(commandForSend1); //Delete the command from results @@ -66,8 +63,7 @@ test await t.expect(workbenchPage.queryCardCommand.withExactText(commandForSend1).exists).notOk(`Command ${commandForSend1} is deleted from table with results`); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see the results found in the table view by default for FT.INFO, FT.SEARCH and FT.AGGREGATE', async t => { + .meta({ rte: rte.standalone })('Verify that user can see the results found in the table view by default for FT.INFO, FT.SEARCH and FT.AGGREGATE', async t => { const commands = [ 'FT.INFO', 'FT.SEARCH', @@ -79,10 +75,8 @@ test await t.expect(await workbenchPage.queryCardContainer.nth(0).find(workbenchPage.cssTableViewTypeOption).visible).ok(`The table view is selected by default for command ${command}`); } }); -//skipped due the inaccessibility of the iframe -test.skip - .meta({ env: env.web, rte: rte.standalone }) - ('Verify that user can switches between views and see results according to the view rules in Workbench in results', async t => { +test + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user can switches between views and see results according to the view rules in Workbench in results', async t => { indexName = chance.word({ length: 5 }); const commands = [ 'hset doc:10 title "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud" url "redis.io" author "Test" rate "undefined" review "0" comment "Test comment"', @@ -90,7 +84,7 @@ test.skip `FT.SEARCH ${indexName} * limit 0 10000` ]; //Send commands and check table view is default for Search command - for(let command of commands) { + for(const command of commands) { await workbenchPage.sendCommandInWorkbench(command); } await t.expect(await workbenchPage.queryCardContainer.nth(0).find(workbenchPage.cssTableViewTypeOption).visible).ok('The table view is selected by default for command FT.SEARCH'); @@ -103,8 +97,7 @@ test.skip }); //skipped due the inaccessibility of the iframe test.skip - .meta({ env: env.web, rte: rte.standalone }) - ('Verify that user can switches between Table and Text for Client List and see results corresponding to their views', async t => { + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user can switches between Table and Text for Client List and see results corresponding to their views', async t => { const command = 'CLIENT LIST'; //Send command and check table view is default await workbenchPage.sendCommandInWorkbench(command); @@ -120,8 +113,7 @@ test //Drop database await deleteDatabase(ossStandaloneConfig.databaseName); }) - .meta({ rte: rte.standalone }) - ('Verify that user can populate commands in Editor from history by clicking keyboard “up” button', async t => { + .meta({ rte: rte.standalone })('Verify that user can populate commands in Editor from history by clicking keyboard “up” button', async t => { const commands = [ 'FT.INFO', 'RANDOMKEY', @@ -135,7 +127,7 @@ test await t.click(workbenchPage.queryInput); for(const command of commands.reverse()) { await t.pressKey('up'); - let script = await workbenchPage.scriptsLines.textContent; + const script = await workbenchPage.scriptsLines.textContent; await t.expect(script.replace(/\s/g, ' ')).contains(command, 'Result of Manual command is displayed'); } }); diff --git a/tests/e2e/tests/critical-path/workbench/default-scripts-area.e2e.ts b/tests/e2e/tests/critical-path/workbench/default-scripts-area.e2e.ts index 851f3b16ea..874a1c4e0a 100644 --- a/tests/e2e/tests/critical-path/workbench/default-scripts-area.e2e.ts +++ b/tests/e2e/tests/critical-path/workbench/default-scripts-area.e2e.ts @@ -1,9 +1,8 @@ +import { Chance } from 'chance'; import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; -import { WorkbenchPage } from '../../../pageObjects/workbench-page'; +import { WorkbenchPage, MyRedisDatabasePage } from '../../../pageObjects'; import { rte, env } from '../../../helpers/constants'; -import { MyRedisDatabasePage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); @@ -20,15 +19,14 @@ fixture `Default scripts area at Workbench` //Go to Workbench page await t.click(myRedisDatabasePage.workbenchButton); }) - .afterEach(async () => { + .afterEach(async t => { //Drop index, documents and database + await t.switchToMainWindow(); await workbenchPage.sendCommandInWorkbench(`FT.DROPINDEX ${indexName} DD`); await deleteDatabase(ossStandaloneConfig.databaseName); - }) -//skipped due the inaccessibility of the iframe -test.skip - .meta({ env: env.web, rte: rte.standalone }) - ('Verify that user can edit and run automatically added "FT._LIST" and "FT.INFO {index}" scripts in Workbench and see the results', async t => { + }); +test + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user can edit and run automatically added "FT._LIST" and "FT.INFO {index}" scripts in Workbench and see the results', async t => { indexName = chance.word({ length: 5 }); keyName = chance.word({ length: 5 }); const commandsForSend = [ @@ -38,29 +36,25 @@ test.skip ]; //Send commands await workbenchPage.sendCommandInWorkbench(commandsForSend.join('\n')); - //Run automatically added "FT._LIST" script + //Run automatically added "FT._LIST" and "FT.INFO {index}" scripts + await t.click(workbenchPage.documentButtonInQuickGuides); await t.click(workbenchPage.internalLinkWorkingWithHashes); - await t.click(workbenchPage.preselectList); - await t.click(workbenchPage.submitCommandButton); - //Check the FT._LIST result - await t.expect(workbenchPage.queryTextResult.textContent).contains(indexName, 'The result of the FT._LIST command'); - //Run automatically added "FT.INFO {index}" script with added index - await t.click(workbenchPage.preselectIndexInfo); - let addedScript = await workbenchPage.queryInputScriptArea.nth(3).textContent; + await t.click(workbenchPage.preselectIndexInformation); //Replace the {index} with indexName value in script and send - addedScript = addedScript.replace('"permits"', indexName); + let addedScript = await workbenchPage.queryInputScriptArea.nth(2).textContent; + addedScript = addedScript.replace('"idx:schools"', indexName); addedScript = addedScript.replace(/\s/g, ' '); + await t.click(workbenchPage.submitCommandButton); await t.pressKey('ctrl+a delete'); await workbenchPage.sendCommandInWorkbench(addedScript); + //Check the FT._LIST result + await t.expect(workbenchPage.queryTextResult.textContent).contains(indexName, 'The result of the FT._LIST command'); //Check the FT.INFO result await t.switchToIframe(workbenchPage.iframe); await t.expect(workbenchPage.queryColumns.textContent).contains('name', 'The result of the FT.INFO command'); - await t.switchToMainWindow(); }); -//skipped due the inaccessibility of the iframe -test.skip - .meta({ env: env.web, rte: rte.standalone }) - ('Verify that user can edit and run automatically added "Search" script in Workbench and see the results', async t => { +test + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user can edit and run automatically added "Search" script in Workbench and see the results', async t => { indexName = chance.word({ length: 5 }); keyName = chance.word({ length: 5 }); const commandsForSend = [ @@ -68,10 +62,11 @@ test.skip `HMSET product:1 name "${keyName}"`, `HMSET product:2 name "${keyName}"` ]; - const searchCommand = `FT.SEARCH "${indexName}" "Apple Juice"`; + const searchCommand = `FT.SEARCH ${indexName} "${keyName}"`; //Send commands await workbenchPage.sendCommandInWorkbench(commandsForSend.join('\n')); //Run automatically added FT.SEARCH script with edits + await t.click(workbenchPage.documentButtonInQuickGuides); await t.click(workbenchPage.internalLinkWorkingWithHashes); await t.click(workbenchPage.preselectExactSearch); await t.pressKey('ctrl+a delete'); @@ -79,15 +74,12 @@ test.skip //Check the FT.SEARCH result await t.switchToIframe(workbenchPage.iframe); const key = workbenchPage.queryTableResult.withText('product:1'); - const name = workbenchPage.queryTableResult.withText('Apple Juice'); + const name = workbenchPage.queryTableResult.withText(keyName); await t.expect(key.exists).ok('The added key is in the Search result'); await t.expect(name.exists).ok('The added key name field is in the Search result'); - await t.switchToMainWindow(); }); -//skipped due the inaccessibility of the iframe -test.skip - .meta({ env: env.web, rte: rte.standalone }) - ('Verify that user can edit and run automatically added "Aggregate" script in Workbench and see the results', async t => { +test + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user can edit and run automatically added "Aggregate" script in Workbench and see the results', async t => { indexName = chance.word({ length: 5 }); const aggregationResultField = 'max_price'; const commandsForSend = [ @@ -99,6 +91,7 @@ test.skip //Send commands await workbenchPage.sendCommandInWorkbench(commandsForSend.join('\n'), 0.5); //Run automatically added FT.Aggregate script with edits + await t.click(workbenchPage.documentButtonInQuickGuides); await t.click(workbenchPage.internalLinkWorkingWithHashes); await t.click(workbenchPage.preselectGroupBy); await t.pressKey('ctrl+a delete'); @@ -107,11 +100,9 @@ test.skip await t.switchToIframe(workbenchPage.iframe); await t.expect(workbenchPage.queryTableResult.textContent).contains(aggregationResultField, 'The aggregation field name is in the Search result'); await t.expect(workbenchPage.queryTableResult.textContent).contains('100', 'The aggregation max value is in the Search result'); - await t.switchToMainWindow(); }); test - .meta({ rte: rte.standalone }) - ('Verify that when the “Manual” option clicked, user can see the Editor is automatically prepopulated with the information', async t => { + .meta({ rte: rte.standalone })('Verify that when the “Manual” option clicked, user can see the Editor is automatically prepopulated with the information', async t => { const information = [ '// Workbench is the advanced Redis command-line interface that allows to send commands to Redis, read and visualize the replies sent by the server.', '// Enter multiple commands at different rows to run them at once.', @@ -119,7 +110,7 @@ test ]; //Click on the Manual option await t.click(workbenchPage.preselectManual); - //Resize the scriptiong area + //Resize the scripting area const offsetY = 200; await t.drag(workbenchPage.resizeButtonForScriptingAndResults, 0, offsetY, { speed: 0.4 }); //Check the result diff --git a/tests/e2e/tests/critical-path/workbench/index-schema.e2e.ts b/tests/e2e/tests/critical-path/workbench/index-schema.e2e.ts index 6a08a6a6f8..e1a4082e6a 100644 --- a/tests/e2e/tests/critical-path/workbench/index-schema.e2e.ts +++ b/tests/e2e/tests/critical-path/workbench/index-schema.e2e.ts @@ -18,19 +18,18 @@ fixture `Index Schema at Workbench` //Go to Workbench page await t.click(myRedisDatabasePage.workbenchButton); }) - .afterEach(async () => { + .afterEach(async t => { //Drop index, documents and database + await t.switchToMainWindow(); await workbenchPage.sendCommandInWorkbench(`FT.DROPINDEX ${indexName} DD`); await deleteDatabase(ossStandaloneRedisearch.databaseName); - }) -//skipped due the inaccessibility of the iframe -test.skip - .meta({ env: env.web, rte: rte.standalone }) - ('Verify that user can open results in Text and Table views for FT.INFO for Hash in Workbench', async t => { + }); +test + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user can open results in Text and Table views for FT.INFO for Hash in Workbench', async t => { indexName = chance.word({ length: 5 }); const commandsForSend = [ `FT.CREATE ${indexName} ON HASH PREFIX 1 product: SCHEMA name TEXT`, - `HMSET product:1 name "Apple Juice"` + 'HMSET product:1 name "Apple Juice"' ]; const searchCommand = `FT.INFO ${indexName}`; //Send commands @@ -46,10 +45,8 @@ test.skip //Check that result is displayed in Text view await t.expect(workbenchPage.queryTextResult.exists).ok('The result is displayed in Text view'); }); -//skipped due the inaccessibility of the iframe -test.skip - .meta({ env: env.web, rte: rte.standalone }) - ('Verify that user can open results in Text and Table views for FT.INFO for JSON in Workbench', async t => { +test + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user can open results in Text and Table views for FT.INFO for JSON in Workbench', async t => { indexName = chance.word({ length: 5 }); const commandsForSend = [ `FT.CREATE ${indexName} ON JSON SCHEMA $.user.name AS name TEXT $.user.tag AS country TAG`, diff --git a/tests/e2e/tests/critical-path/workbench/json-workbench.e2e.ts b/tests/e2e/tests/critical-path/workbench/json-workbench.e2e.ts index 970ab10de9..72260d8ae4 100644 --- a/tests/e2e/tests/critical-path/workbench/json-workbench.e2e.ts +++ b/tests/e2e/tests/critical-path/workbench/json-workbench.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { env, rte } from '../../../helpers/constants'; import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; import { MyRedisDatabasePage, WorkbenchPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneRedisearch } from '../../../helpers/conf'; -import { Chance } from 'chance'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); @@ -18,22 +18,21 @@ fixture `JSON verifications at Workbench` //Go to Workbench page await t.click(myRedisDatabasePage.workbenchButton); }) - .afterEach(async () => { + .afterEach(async t => { //Drop index, documents and database + await t.switchToMainWindow(); await workbenchPage.sendCommandInWorkbench(`FT.DROPINDEX ${indexName} DD`); await deleteDatabase(ossStandaloneRedisearch.databaseName); - }) -//skipped due the inaccessibility of the iframe -test.skip - .meta({ env: env.web, rte: rte.standalone }) - ('Verify that user can see result in Table and Text view for JSON data types for FT.AGGREGATE command in Workbench', async t => { + }); +test + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user can see result in Table and Text view for JSON data types for FT.AGGREGATE command in Workbench', async t => { indexName = chance.word({ length: 5 }); const commandsForSend = [ `FT.CREATE ${indexName} ON JSON SCHEMA $.user.name AS name TEXT $.user.tag AS country TAG`, `JSON.SET myDoc1 $ '{"user":{"name":"John Smith","tag":"foo,bar","hp":1000, "dmg":150}}'`, `JSON.SET myDoc2 $ '{"user":{"name":"John Smith","tag":"foo,bar","hp":500, "dmg":300}}'` ]; - const searchCommand = 'FT.AGGREGATE userIdx "*" LOAD 6 $.user.hp AS hp $.user.dmg AS dmg APPLY "@hp-@dmg" AS points'; + const searchCommand = `FT.AGGREGATE ${indexName} "*" LOAD 6 $.user.hp AS hp $.user.dmg AS dmg APPLY "@hp-@dmg" AS points`; //Send commands await workbenchPage.sendCommandInWorkbench(commandsForSend.join('\n')); //Send search command diff --git a/tests/e2e/tests/critical-path/workbench/scripting-area.e2e.ts b/tests/e2e/tests/critical-path/workbench/scripting-area.e2e.ts index edc3494167..99850573fc 100644 --- a/tests/e2e/tests/critical-path/workbench/scripting-area.e2e.ts +++ b/tests/e2e/tests/critical-path/workbench/scripting-area.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { rte, env } from '../../../helpers/constants'; import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; import { MyRedisDatabasePage, WorkbenchPage, CliPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); @@ -25,10 +25,9 @@ fixture `Scripting area at Workbench` //Drop index, documents and database await workbenchPage.sendCommandInWorkbench(`FT.DROPINDEX ${indexName} DD`); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can run any script from CLI in Workbench and see the results', async t => { + .meta({ rte: rte.standalone })('Verify that user can run any script from CLI in Workbench and see the results', async t => { const commandForSend = 'info'; //Send command await workbenchPage.sendCommandInWorkbench(commandForSend); @@ -38,17 +37,14 @@ test await t.expect(sentCommandText.exists).ok('Result of sent command exists'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can resize scripting area in Workbench', async t => { + .meta({ rte: rte.standalone })('Verify that user can resize scripting area in Workbench', async t => { const offsetY = 200; const inputHeightStart = await workbenchPage.queryInput.clientHeight; await t.drag(workbenchPage.resizeButtonForScriptingAndResults, 0, offsetY, { speed: 0.4 }); await t.expect(await workbenchPage.queryInput.clientHeight).eql(inputHeightStart + offsetY, 'Scripting area after resize has proper size'); }); -//skipped due the inaccessibility of the iframe -test.skip - .meta({ env: env.web, rte: rte.standalone }) - ('Verify that user when he have more than 10 results can request to view more results in Workbench', async t => { +test + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user when he have more than 10 results can request to view more results in Workbench', async t => { indexName = chance.word({ length: 5 }); keyName = chance.word({ length: 5 }); const commandsForSendInCli = [ @@ -63,7 +59,7 @@ test.skip `HMSET product:9 name "${keyName}"`, `HMSET product:10 name "${keyName}"`, `HMSET product:11 name "${keyName}"`, - `HMSET product:12 name "${keyName}"`, + `HMSET product:12 name "${keyName}"` ]; const commandToCreateSchema = `FT.CREATE ${indexName} ON HASH PREFIX 1 product: SCHEMA name TEXT`; const searchCommand = `FT.SEARCH ${indexName} * LIMIT 0 20`; @@ -79,19 +75,13 @@ test.skip await workbenchPage.sendCommandInWorkbench(commandToCreateSchema); //Send search command await workbenchPage.sendCommandInWorkbench(searchCommand); - //Get needed container - const containerOfCommand = await workbenchPage.getCardContainerByCommand(searchCommand); //Verify that we have pagination buttons await t.switchToIframe(workbenchPage.iframe); - await t.expect(containerOfCommand.find(workbenchPage.cssSelectorPaginationButtonPrevious).exists) - .ok('Pagination previous button exists'); - await t.expect(containerOfCommand.find(workbenchPage.cssSelectorPaginationButtonNext).exists) - .ok('Pagination next button exists'); + await t.expect(workbenchPage.paginationButtonPrevious.exists).ok('Pagination previous button exists'); + await t.expect(workbenchPage.paginationButtonNext.exists).ok('Pagination next button exists'); }); -//skipped due the inaccessibility of the iframe -test.skip - .meta({ env: env.web, rte: rte.standalone }) - ('Verify that user can see result in Table and Text views for Hash data types for FT.SEARCH command in Workbench', async t => { +test + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user can see result in Table and Text views for Hash data types for FT.SEARCH command in Workbench', async t => { indexName = chance.word({ length: 5 }); keyName = chance.word({ length: 5 }); const commandsForSend = [ @@ -114,8 +104,7 @@ test.skip await t.expect(workbenchPage.queryTextResult.exists).ok('The result is displayed in Text view'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can run one command in multiple lines in Workbench page', async t => { + .meta({ rte: rte.standalone })('Verify that user can run one command in multiple lines in Workbench page', async t => { indexName = chance.word({ length: 5 }); const multipleLinesCommand = [ `FT.CREATE ${indexName}`, @@ -131,8 +120,7 @@ test } }); test - .meta({ rte: rte.standalone }) - ('Verify that user can use one indent to indicate command in several lines in Workbench page', async t => { + .meta({ rte: rte.standalone })('Verify that user can use one indent to indicate command in several lines in Workbench page', async t => { indexName = chance.word({ length: 5 }); const multipleLinesCommand = [ `FT.CREATE ${indexName}`, diff --git a/tests/e2e/tests/regression/browser/add-keys.e2e.ts b/tests/e2e/tests/regression/browser/add-keys.e2e.ts new file mode 100644 index 0000000000..6bf6d4e008 --- /dev/null +++ b/tests/e2e/tests/regression/browser/add-keys.e2e.ts @@ -0,0 +1,43 @@ +import { rte } from '../../../helpers/constants'; +import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; +import { BrowserPage, CliPage } from '../../../pageObjects'; +import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; + +const browserPage = new BrowserPage(); +const cliPage = new CliPage(); +const jsonKeys = [['JSON-string', '"test"'], ['JSON-number', '782364'], ['JSON-boolean', 'true'], ['JSON-null', 'null'], ['JSON-array', '[1, 2, 3]']]; + +fixture `Different JSON types creation` + .meta({ + type: 'regression', + rte: rte.standalone + }) + .page(commonUrl) + .beforeEach(async() => { + await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); + }) + .afterEach(async() => { + let commandString = 'DEL'; + for (const key of jsonKeys) { + commandString = commandString.concat(` ${key[0]}`); + } + await cliPage.sendCommandInCli(commandString); + await deleteDatabase(ossStandaloneConfig.databaseName); + }); +test('Verify that user can create different types(string, number, null, array, boolean) of JSON', async t => { + for (let i = 0; i < jsonKeys.length; i++) { + await browserPage.addJsonKey(jsonKeys[i][0], jsonKeys[i][1]); + await t.click(browserPage.toastCloseButton); + await t.click(browserPage.refreshKeysButton); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(jsonKeys[i][0])).ok('New keys is displayed'); + // Add additional check for array elements + if (jsonKeys[i][0].includes('array')) { + for (const j of JSON.parse(jsonKeys[i][1])) { + await t.expect(browserPage.jsonScalarValue.withText(j.toString()).exists).ok('JSON value'); + } + } + else { + await t.expect(browserPage.jsonKeyValue.withText(jsonKeys[i][1]).exists).ok('JSON value'); + } + } +}); diff --git a/tests/e2e/tests/regression/browser/database-info.e2e.ts b/tests/e2e/tests/regression/browser/database-info.e2e.ts index 08e445917e..98d51274b5 100644 --- a/tests/e2e/tests/regression/browser/database-info.e2e.ts +++ b/tests/e2e/tests/regression/browser/database-info.e2e.ts @@ -14,28 +14,26 @@ const browserPage = new BrowserPage(); fixture `Database info tooltips` .meta({type: 'regression'}) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Delete database await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see an (i) icon next to the database name on Browser and Workbench pages', async t => { - await t.expect(browserPage.databaseInfoIcon.visible).ok('User can see (i) icon on Browser page', { timeout: 20000 }); + .meta({ rte: rte.standalone })('Verify that user can see an (i) icon next to the database name on Browser and Workbench pages', async t => { + await t.expect(browserPage.databaseInfoIcon.visible).ok('User can see (i) icon on Browser page', { timeout: 10000 }); //Move to the Workbench page and check icon await t.click(myRedisDatabasePage.workbenchButton); - await t.expect(workbenchPage.overviewTotalMemory.visible).ok('User can see (i) icon on Workbench page', { timeout: 20000 }); + await t.expect(workbenchPage.overviewTotalMemory.visible).ok('User can see (i) icon on Workbench page', { timeout: 10000 }); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see DB name, endpooint, connection type, Redis version, user name in tooltip when hover over the (i) icon', async t => { + .meta({ rte: rte.standalone })('Verify that user can see DB name, endpoint, connection type, Redis version, user name in tooltip when hover over the (i) icon', async t => { const version = /[0-9].[0-9].[0-9]/; await t.hover(browserPage.databaseInfoIcon); await t.expect(browserPage.databaseInfoToolTip.textContent).contains(ossStandaloneConfig.databaseName, 'User can see database name in tooltip'); - await t.expect(browserPage.databaseInfoToolTip.textContent).contains(`${ossStandaloneConfig.host}:${ossStandaloneConfig.port}`, 'User can see endpooint in tooltip'); + await t.expect(browserPage.databaseInfoToolTip.textContent).contains(`${ossStandaloneConfig.host}:${ossStandaloneConfig.port}`, 'User can see endpoint in tooltip'); await t.expect(browserPage.databaseInfoToolTip.textContent).contains('Standalone', 'User can see connection type in tooltip'); await t.expect(browserPage.databaseInfoToolTip.textContent).match(version, 'User can see Redis version in tooltip'); await t.expect(browserPage.databaseInfoToolTip.textContent).contains('Default', 'User can see user name in tooltip'); diff --git a/tests/e2e/tests/regression/browser/database-overview.e2e.ts b/tests/e2e/tests/regression/browser/database-overview.e2e.ts index b4a205c873..ceb4ff842c 100644 --- a/tests/e2e/tests/regression/browser/database-overview.e2e.ts +++ b/tests/e2e/tests/regression/browser/database-overview.e2e.ts @@ -20,17 +20,16 @@ let keys: string[]; fixture `Database overview` .meta({type: 'regression'}) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await cliPage.sendCommandInCli(`DEL ${keys.join(' ')}`); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see total memory and total number of keys updated in DB header in Workbench page', async t => { + .meta({ rte: rte.standalone })('Verify that user can see total memory and total number of keys updated in DB header in Workbench page', async t => { //Create new keys keys = await common.createArrayWithKeyValue(10); await cliPage.sendCommandInCli(`MSET ${keys.join(' ')}`); @@ -42,13 +41,12 @@ test }); test .meta({ rte: rte.standalone }) - .after(async () => { + .after(async() => { //Delete database await deleteDatabase(ossStandaloneConfig.databaseName); - }) - ('Verify that user can connect to DB and see breadcrumbs at the top of the application', async t => { + })('Verify that user can connect to DB and see breadcrumbs at the top of the application', async t => { //Verify that user can see breadcrumbs in Browser and Workbench views - await t.expect(browserPage.breadcrumbsContainer.visible).ok('User can see breadcrumbs in Browser page', { timeout: 20000 }); + await t.expect(browserPage.breadcrumbsContainer.visible).ok('User can see breadcrumbs in Browser page', { timeout: 10000 }); await t.click(myRedisDatabasePage.workbenchButton); - await t.expect(browserPage.breadcrumbsContainer.visible).ok('User can see breadcrumbs in Workbench page', { timeout: 20000 }); + await t.expect(browserPage.breadcrumbsContainer.visible).ok('User can see breadcrumbs in Workbench page', { timeout: 10000 }); }); diff --git a/tests/e2e/tests/regression/browser/full-screen.e2e.ts b/tests/e2e/tests/regression/browser/full-screen.e2e.ts new file mode 100644 index 0000000000..196f5f13f6 --- /dev/null +++ b/tests/e2e/tests/regression/browser/full-screen.e2e.ts @@ -0,0 +1,108 @@ +import { Chance } from 'chance'; +import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; +import { BrowserPage } from '../../../pageObjects'; +import { rte } from '../../../helpers/constants'; +import {commonUrl, ossStandaloneConfig} from '../../../helpers/conf'; + +const browserPage = new BrowserPage(); +const chance = new Chance(); + +const keyName = chance.word({ length: 20 }); +const keyValue = chance.word({ length: 20 }); + +fixture `Full Screen` + .meta({type: 'regression'}) + .page(commonUrl) + .beforeEach(async() => { + await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); + }) + .afterEach(async() => { + //Clear and delete database + await deleteDatabase(ossStandaloneConfig.databaseName); + }); +test + .before(async() => { + await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await browserPage.addStringKey(keyName, keyValue); + await browserPage.openKeyDetails(keyName); + }) + .after(async() => { + await browserPage.deleteKeyByName(keyName); + await deleteDatabase(ossStandaloneConfig.databaseName); + }) + .meta({ rte: rte.standalone })('Verify that user can switch to full screen from key details in Browser', async t => { + // Save tables size before switching to full screen mode + const widthBeforeFullScreen = await browserPage.keyDetailsTable.clientWidth; + // Switch to full screen mode + await t.click(browserPage.fullScreenModeButton); + // Compare size of details table after switching + const widthAfterFullScreen = await browserPage.keyDetailsTable.clientWidth; + await t.expect(widthAfterFullScreen).gt(widthBeforeFullScreen, 'Width after switching to full screen'); + await t.expect(browserPage.keyNameFormDetails.withExactText(keyName).exists).ok('Key Details Table'); + await t.expect(browserPage.stringKeyValueInput.withExactText(keyValue).exists).ok('Key Value in Details'); + // Verify that user can exit full screen in key details and two tables with keys and key details are displayed + await t.click(browserPage.fullScreenModeButton); + const widthAfterExitFullScreen = await browserPage.keyDetailsTable.clientWidth; + await t.expect(widthAfterExitFullScreen).lt(widthAfterFullScreen, 'Width after switching from full screen'); + }); +test + .meta({ rte: rte.standalone })('Verify that when no keys are selected user can click on "Close" control for right table and see key list in full screen', async t => { + // Verify that user sees two panels(key list and empty details panel) opening Browser page for the first time + await t.expect(browserPage.noKeysToDisplayText.visible).ok('No keys selected panel'); + // Save key table size before switching to full screen + const widthKeysBeforeFullScreen = await browserPage.keyListTable.clientWidth; + // Close right panel with key details + await t.expect(browserPage.keyNameFormDetails.withExactText(keyName).exists).notOk('Key Details Table'); + await t.click(browserPage.closeRightPanel); + // Check that table is in full screen + const widthTableAfterFullScreen = await browserPage.keyListTable.clientWidth; + await t.expect(widthTableAfterFullScreen).gt(widthKeysBeforeFullScreen, 'Width after switching to full screen'); + }); +test + .before(async() => { + await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await browserPage.addSetKey(keyName, keyValue); + await browserPage.openKeyDetails(keyName); + }) + .after(async() => { + await browserPage.deleteKeyByName(keyName); + await deleteDatabase(ossStandaloneConfig.databaseName); + }) + .meta({ rte: rte.standalone })('Verify that when user closes key details in full screen mode the list of keys displayed in full screen', async t => { + // Save keys table size before switching to full screen + const widthKeysBeforeFullScreen = await browserPage.keyListTable.clientWidth; + // Open full mode for key details + await t.click(browserPage.fullScreenModeButton); + // Close key details + await t.click(browserPage.closeKeyButton); + // Check that key list is opened in full screen + const widthTableAfterFullScreen = await browserPage.keyListTable.clientWidth; + await t.expect(widthTableAfterFullScreen).gt(widthKeysBeforeFullScreen, 'Width after switching to full screen'); + // Verify that when user selects the key while key list is in full screen, key details is opened on the right side panel + const widthKeysBeforeExitFullScreen = await browserPage.keyListTable.clientWidth; + await browserPage.openKeyDetails(keyName); + const widthKeysAfterExitFullScreen = await browserPage.keyListTable.clientWidth; + await t.expect(widthKeysAfterExitFullScreen).lt(widthKeysBeforeExitFullScreen, 'Width after switching from full screen'); + await t.expect(browserPage.keyNameFormDetails.withExactText(keyName).exists).ok('Key details opened'); + }); +test + .before(async() => { + await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await browserPage.addHashKey(keyName, '58965422', 'filed', 'value'); + await browserPage.openKeyDetails(keyName); + }) + .after(async() => { + await browserPage.deleteKeyByName(keyName); + await deleteDatabase(ossStandaloneConfig.databaseName); + }) + .meta({ rte: rte.standalone })('Verify that when users close key details not in full mode, they can see full key list screen', async t => { + // Save key list table size before switching to full screen + const widthKeysBeforeFullScreen = await browserPage.keyListTable.clientWidth; + // Close key details + await t.click(browserPage.closeKeyButton); + // Check that key list is opened in full screen + const widthTableAfterFullScreen = await browserPage.keyListTable.clientWidth; + await t.expect(widthTableAfterFullScreen).gt(widthKeysBeforeFullScreen, 'Width after switching to full screen'); + // Verify that user can not see key details + await t.expect(browserPage.keyNameFormDetails.withExactText(keyName).exists).notOk('Key details opened'); + }); diff --git a/tests/e2e/tests/regression/browser/last-refresh.e2e.ts b/tests/e2e/tests/regression/browser/last-refresh.e2e.ts index bde0da5e5b..ff8d61f08d 100644 --- a/tests/e2e/tests/regression/browser/last-refresh.e2e.ts +++ b/tests/e2e/tests/regression/browser/last-refresh.e2e.ts @@ -30,7 +30,7 @@ test //Hover on the refresh icon await t.hover(browserPage.refreshKeysButton); //Verify the last update info - await t.expect(browserPage.tooltip.innerText).contains('Last Refresh\nless than a minute ago', 'tooltip text'); + await t.expect(browserPage.tooltip.innerText).contains('Last Refresh\nnow', 'tooltip text'); }); test .meta({ rte: rte.standalone }) @@ -43,11 +43,11 @@ test await t.wait(120000); //Hover on the refresh icon await t.hover(browserPage.refreshKeyButton); - await t.expect(browserPage.tooltip.innerText).contains('Last Refresh\n2 minutes ago', 'tooltip text'); + await t.expect(browserPage.tooltip.innerText).contains('Last Refresh\n2 min', 'tooltip text'); //Click on Refresh and check last refresh await t.click(browserPage.refreshKeyButton); await t.hover(browserPage.refreshKeyButton); - await t.expect(browserPage.tooltip.innerText).contains('Last Refresh\nless than a minute ago', 'tooltip text'); + await t.expect(browserPage.tooltip.innerText).contains('Last Refresh\nnow', 'tooltip text'); }); test .meta({ rte: rte.standalone }) @@ -59,7 +59,7 @@ test //Hover on the refresh icon await t.hover(browserPage.refreshKeyButton); //Verify the last update info - await t.expect(browserPage.tooltip.innerText).contains('Last Refresh\nless than a minute ago', 'tooltip text'); + await t.expect(browserPage.tooltip.innerText).contains('Last Refresh\nnow', 'tooltip text'); }); test .meta({ rte: rte.standalone }) @@ -71,9 +71,9 @@ test //Hover on the keys refresh icon await t.hover(browserPage.refreshKeysButton); //Verify the last update info - await t.expect(browserPage.tooltip.innerText).contains('Last Refresh\nless than a minute ago', 'tooltip text'); + await t.expect(browserPage.tooltip.innerText).contains('Last Refresh\nnow', 'tooltip text'); //Hover on the key in details refresh icon await t.hover(browserPage.refreshKeyButton); //Verify the last update info - await t.expect(browserPage.tooltip.innerText).contains('Last Refresh\nless than a minute ago', 'tooltip text'); + await t.expect(browserPage.tooltip.innerText).contains('Last Refresh\nnow', 'tooltip text'); }); diff --git a/tests/e2e/tests/regression/browser/scan-keys.e2e.ts b/tests/e2e/tests/regression/browser/scan-keys.e2e.ts index 61268af588..51924c526d 100644 --- a/tests/e2e/tests/regression/browser/scan-keys.e2e.ts +++ b/tests/e2e/tests/regression/browser/scan-keys.e2e.ts @@ -11,24 +11,23 @@ const explicitErrorHandler = (): void => { if(e.message === 'ResizeObserver loop limit exceeded') { e.stopImmediatePropagation(); } - }) -} + }); +}; fixture `Browser - Specify Keys to Scan` .meta({type: 'regression'}) .page(commonUrl) .clientScripts({ content: `(${explicitErrorHandler.toString()})()` }) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTerms(); - }) + }); test - .meta({ rte: rte.none }) - ('Verify that the user not enter the value less than 500 - the system automatically applies min value if user enters less than min', async t => { + .meta({ rte: rte.none })('Verify that the user not enter the value less than 500 - the system automatically applies min value if user enters less than min', async t => { //Go to Settings page await t.click(myRedisDatabasePage.settingsButton); //Specify keys to scan less than 500 await t.click(settingsPage.accordionAdvancedSettings); await settingsPage.changeKeysToScanValue('100'); - //Verify the applyed scan value + //Verify the applied scan value await t.expect(await settingsPage.keysToScanValue.textContent).eql('500', 'The system automatically applies min value 500'); }); diff --git a/tests/e2e/tests/regression/browser/stream-key.e2e.ts b/tests/e2e/tests/regression/browser/stream-key.e2e.ts new file mode 100644 index 0000000000..968e194018 --- /dev/null +++ b/tests/e2e/tests/regression/browser/stream-key.e2e.ts @@ -0,0 +1,124 @@ +import { Chance } from 'chance'; +import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; +import { rte } from '../../../helpers/constants'; +import { BrowserPage, CliPage } from '../../../pageObjects'; +import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; + +const browserPage = new BrowserPage(); +const cliPage = new CliPage(); +const chance = new Chance(); + +const value = chance.word({length: 5}); +let field = chance.word({length: 5}); +let keyName = chance.word({length: 20}); + +fixture `Stream key` + .meta({ + type: 'regression', + rte: rte.standalone + }) + .page(commonUrl) + .beforeEach(async() => { + await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); + }) + .afterEach(async() => { + await browserPage.deleteKeyByName(keyName); + await deleteDatabase(ossStandaloneConfig.databaseName); + }); +test('Verify that user can see a Stream in a table format', async t => { + const streamFields = [ + 'Entry ID', + field + ]; + keyName = chance.word({length: 20}); + const command = `XADD ${keyName} * '${field}' '${value}'`; + //Add new Stream key with 5 EntryIds + for(let i = 0; i < 5; i++){ + await cliPage.sendCommandInCli(command); + } + //Open key details and check Steam format + await browserPage.openKeyDetails(keyName); + await t.expect(browserPage.virtualTableContainer.visible).ok('The Stream is displayed in a table format'); + for(const field of streamFields){ + await t.expect(browserPage.streamEntriesContainer.textContent).contains(field, 'The Stream fields are in the table'); + } + await t.expect(browserPage.streamFieldsValues.textContent).contains(value, 'The Stream field value is in the table'); +}); +test('Verify that user can sort ASC/DESC by Entry ID', async t => { + keyName = chance.word({length: 20}); + const command = `XADD ${keyName} * '${field}' '${value}'`; + //Add new Stream key with 5 EntryIds + for(let i = 0; i < 5; i++){ + await cliPage.sendCommandInCli(command); + } + //Open key details and check Entry ID ASC sorting + await browserPage.openKeyDetails(keyName); + const entryCount = await browserPage.streamEntryDate.count; + for(let i = 0; i < entryCount - 1; i++){ + const entryDateFirstAsc = Date.parse(await browserPage.streamEntryDate.nth(i).textContent); + const entryDateSecondAsc = Date.parse(await browserPage.streamEntryDate.nth(i + 1).textContent); + await t.expect(entryDateFirstAsc).gt(entryDateSecondAsc, 'By default the table is sorted by Entry ID'); + } + //Check the DESC sorting + await t.click(browserPage.scoreButton); + for(let i = 0; i < entryCount - 1; i++){ + const entryDateFirstDesc = Date.parse(await browserPage.streamEntryDate.nth(i).textContent); + const entryDateSecondDesc = Date.parse(await browserPage.streamEntryDate.nth(i + 1).textContent); + await t.expect(entryDateFirstDesc).lt(entryDateSecondDesc, 'The Stream fields are sorted DESC by Entry ID'); + } +}); +test('Verify that user can see all the columns are displayed by default for Stream', async t => { + keyName = chance.word({length: 20}); + const fields = [ + 'Pressure', + 'Humidity', + 'Temperature' + ]; + const values = [ + '234', + '78', + '27' + ]; + //Add new Stream key with 3 fields + for(let i = 0; i < fields.length; i++){ + await cliPage.sendCommandInCli(`XADD ${keyName} * ${fields[i]} ${values[i]}`); + } + //Open key details and check fields + await browserPage.openKeyDetails(keyName); + await t.click(browserPage.fullScreenModeButton); + for(let i = fields.length - 1; i <= 0; i--){ + const fieldName = await browserPage.streamFields.nth(i).textContent; + await t.expect(fieldName).eql(fields[i], 'All the columns are displayed by default for Stream'); + } + await t.click(browserPage.fullScreenModeButton); +}); +test('Verify that the multi-line cell value tooltip is available on hover as per standard key details behavior', async t => { + keyName = chance.word({length: 20}); + const fields = [ + 'Pressure', + 'Humidity' + ]; + const entryValue = chance.sentence({words: 5}); + //Add new Stream key with multi-line cell value + for(let i = 0; i < fields.length; i++){ + await cliPage.sendCommandInCli(`XADD ${keyName} * '${fields[i]}' '${entryValue}'`); + } + //Open key details and check tooltip + await browserPage.openKeyDetails(keyName); + await t.hover(browserPage.streamEntryFields); + await t.expect(browserPage.tooltip.textContent).contains(entryValue, 'The multi-line cell value tooltip is available'); +}); +test('Verify that user can see a confirmation message when request to delete an entry in the Stream', async t => { + keyName = chance.word({length: 20}); + field = 'fieldForRemoving'; + const confirmationMessage = `will be removed from ${keyName}`; + //Add new Stream key with 1 field + await cliPage.sendCommandInCli(`XADD ${keyName} * ${field} ${value}`); + //Open key details and click on delete entry + await browserPage.openKeyDetails(keyName); + const entryId = await browserPage.streamEntryIdValue.textContent; + await t.click(browserPage.removeEntryButton); + //Check the confirmation message + await t.expect(browserPage.confirmationMessagePopover.textContent).contains(confirmationMessage, `The confirmation message ${keyName}`); + await t.expect(browserPage.confirmationMessagePopover.textContent).contains(entryId, `The confirmation message for removing Entry`); +}); diff --git a/tests/e2e/tests/regression/browser/ttl-format.e2e.ts b/tests/e2e/tests/regression/browser/ttl-format.e2e.ts index 9b13469c92..2b18b1f234 100644 --- a/tests/e2e/tests/regression/browser/ttl-format.e2e.ts +++ b/tests/e2e/tests/regression/browser/ttl-format.e2e.ts @@ -1,54 +1,78 @@ +import { Chance } from 'chance'; import { Selector } from 'testcafe'; import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; import { keyTypes } from '../../../helpers/keys'; -import { rte } from '../../../helpers/constants'; -import { COMMANDS_TO_CREATE_KEY, keyLength } from '../../../helpers/constants'; +import { rte, COMMANDS_TO_CREATE_KEY, keyLength } from '../../../helpers/constants'; import { BrowserPage, CliPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const browserPage = new BrowserPage(); const cliPage = new CliPage(); const chance = new Chance(); +const keyName = chance.word({length: 20}); const keysData = keyTypes.slice(0, 6); for (const key of keysData) { - key.keyName = `${key.keyName}` + '-' + `${chance.word({ length: keyLength })}` + key.keyName = `${key.keyName}` + '-' + `${chance.word({length: keyLength})}`; } //Arrays with TTL in seconds, min, hours, days, months, years and their values in Browser Page const ttlForSet = [59, 800, 20000, 2000000, 31000000, 2147483647]; const ttlValues = ['s', '13 min', '5 h', '23 d', '11 mo', '68 yr']; fixture `TTL values in Keys Table` - .meta({ type: 'regression' }) + .meta({ + type: 'regression', + rte: rte.standalone + }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database for (let i = 0; i < keysData.length; i++) { await browserPage.deleteKey(); } await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); +test('Verify that user can see TTL in the list of keys rounded down to the nearest unit', async t => { + //Create new keys with TTL + await t.click(cliPage.cliExpandButton); + for (let i = 0; i < keysData.length; i++) { + await t.typeText(cliPage.cliCommandInput, COMMANDS_TO_CREATE_KEY[keysData[i].textType](keysData[i].keyName), {paste: true}); + await t.pressKey('enter'); + await t.typeText(cliPage.cliCommandInput, `EXPIRE ${keysData[i].keyName} ${ttlForSet[i]}`, {paste: true}); + await t.pressKey('enter'); + } + await t.click(cliPage.cliCollapseButton); + //Refresh Keys in Browser + await t.click(browserPage.refreshKeysButton); + //Check that Keys has correct TTL value in keys table + for (let i = 0; i < keysData.length; i++) { + const ttlValueElement = Selector(`[data-testid="ttl-${keysData[i].keyName}"]`); + await t.expect(ttlValueElement.textContent).contains(ttlValues[i], `TTL value in keys table is ${ttlValues[i]}`); + } +}); test - .meta({ rte: rte.standalone }) - ('Verify that user can see TTL in the list of keys rounded down to the nearest unit', async t => { - //Create new keys with TTL - await t.click(cliPage.cliExpandButton); - for (let i = 0; i < keysData.length; i++) { - await t.typeText(cliPage.cliCommandInput, COMMANDS_TO_CREATE_KEY[keysData[i].textType](keysData[i].keyName), { paste: true }); - await t.pressKey('enter'); - await t.typeText(cliPage.cliCommandInput, `EXPIRE ${keysData[i].keyName} ${ttlForSet[i]}`, { paste: true }); - await t.pressKey('enter'); - } - await t.click(cliPage.cliCollapseButton); - //Refresh Keys in Browser + .after(async() => { + await deleteDatabase(ossStandaloneConfig.databaseName); + })('Verify that Key is deleted if TTL finishes', async t => { + // Create new key with TTL + const TTL = 15; + let ttlToCompare = TTL; + await browserPage.addStringKey(keyName, 'test', TTL.toString()); await t.click(browserPage.refreshKeysButton); - //Check that Keys has correct TTL value in keys table - for (let i = 0; i < keysData.length; i++) { - const ttlValueElement = Selector(`[data-testid="ttl-${keysData[i].keyName}"]`); - await t.expect(ttlValueElement.textContent).contains(ttlValues[i], `TTL value in keys table is ${ttlValues[i]}`); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyName)).ok('Added key'); + // Specify selector with TTL + const ttlValueElement = Selector(`[data-testid="ttl-${keyName}"]`); + // Check that TTL reduces every page refresh + while (await browserPage.isKeyIsDisplayedInTheList(keyName)) { + const actualTTL = Number((await ttlValueElement.innerText).slice(0, -2)); + await t.expect(actualTTL).lte(ttlToCompare); + await t.click(browserPage.refreshKeysButton); + ttlToCompare = actualTTL; } + // Check that key with finished TTL is deleted + await t.click(browserPage.refreshKeysButton); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyName)).notOk('Not displayed key'); }); diff --git a/tests/e2e/tests/regression/cli/cli-command-helper.e2e.ts b/tests/e2e/tests/regression/cli/cli-command-helper.e2e.ts index c1bea61a99..6a0d8210ad 100644 --- a/tests/e2e/tests/regression/cli/cli-command-helper.e2e.ts +++ b/tests/e2e/tests/regression/cli/cli-command-helper.e2e.ts @@ -217,7 +217,7 @@ test 'BF.MEXISTS key item [item ...]', 'CMS.QUERY key item [item ...]', 'TDIGEST.RESET key', - 'TOPK.LIST key numKeys withcount', + 'TOPK.LIST key withcount', 'CF.ADD key item' ]; externalPageLinks = [ diff --git a/tests/e2e/tests/regression/cli/cli.e2e.ts b/tests/e2e/tests/regression/cli/cli.e2e.ts index 05e251fc11..7880ce3ef4 100644 --- a/tests/e2e/tests/regression/cli/cli.e2e.ts +++ b/tests/e2e/tests/regression/cli/cli.e2e.ts @@ -109,7 +109,7 @@ test keyName = chance.word({ length: 20 }); const jsonValueCli = '"{\\"name\\":\\"xyz\\"}"'; //Add Json key with json object - await browserPage.addJsonKey(keyName, keyTTL, jsonValue); + await browserPage.addJsonKey(keyName, jsonValue, keyTTL); const command = `JSON.GET ${keyName}`; //Open CLI and run command await t.click(cliPage.cliExpandButton); diff --git a/tests/e2e/tests/regression/database/edit-db.e2e.ts b/tests/e2e/tests/regression/database/edit-db.e2e.ts index 4dc7e1a5ea..d90bde9443 100644 --- a/tests/e2e/tests/regression/database/edit-db.e2e.ts +++ b/tests/e2e/tests/regression/database/edit-db.e2e.ts @@ -1,3 +1,4 @@ +import { Chance } from 'chance'; import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; import { MyRedisDatabasePage } from '../../../pageObjects'; import { @@ -5,34 +6,37 @@ import { ossStandaloneConfig } from '../../../helpers/conf'; import { rte } from '../../../helpers/constants'; -import { Chance } from 'chance'; const chance = new Chance(); const myRedisDatabasePage = new MyRedisDatabasePage(); +const database = Object.assign({}, ossStandaloneConfig); -const newDatabaseName = chance.word({ length: 10 }); +const previousDatabaseName = chance.word({ length: 20 }); +const newDatabaseName = chance.word({ length: 20 }); +database.databaseName = previousDatabaseName; fixture `List of Databases` .meta({ type: 'regression' }) .page(commonUrl) - .beforeEach(async () => { - await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); - }) + .beforeEach(async() => { + await acceptLicenseTermsAndAddDatabase(database, database.databaseName); + console.log(`Newly added database name is ${database.databaseName}`); + }); test .meta({ rte: rte.standalone }) - .after(async () => { + .after(async() => { //Delete database await deleteDatabase(newDatabaseName); - }) - ('Verify that user can edit DB alias of Standalone DB', async t => { + })('Verify that user can edit DB alias of Standalone DB', async t => { await t.click(myRedisDatabasePage.myRedisDBButton); //Edit alias of added database - await myRedisDatabasePage.clickOnEditDBByName(ossStandaloneConfig.databaseName); + await myRedisDatabasePage.clickOnEditDBByName(database.databaseName); await t.click(myRedisDatabasePage.editAliasButton); await t.typeText(myRedisDatabasePage.aliasInput, newDatabaseName, { replace: true }); await t.click(myRedisDatabasePage.applyButton); await t.click(myRedisDatabasePage.submitChangesButton); + console.log(`New database name is ${database.databaseName}`); //Verify that database has new alias - await t.expect(myRedisDatabasePage.dbNameList.withExactText(newDatabaseName).exists).ok('The database with new alias is in the list', { timeout: 60000 }); - await t.expect(myRedisDatabasePage.dbNameList.withExactText(ossStandaloneConfig.databaseName).exists).notOk('The database with previous alias is not in the list', { timeout: 60000 }); + await t.expect(myRedisDatabasePage.dbNameList.withExactText(newDatabaseName).exists).ok('The database with new alias is in the list', { timeout: 10000 }); + await t.expect(myRedisDatabasePage.dbNameList.withExactText(previousDatabaseName).exists).notOk('The database with previous alias is not in the list', { timeout: 10000 }); }); diff --git a/tests/e2e/tests/regression/database/overview.e2e.ts b/tests/e2e/tests/regression/database/overview.e2e.ts index 8321e6754a..1da4a2e7b0 100644 --- a/tests/e2e/tests/regression/database/overview.e2e.ts +++ b/tests/e2e/tests/regression/database/overview.e2e.ts @@ -1,12 +1,13 @@ import { t } from 'testcafe'; import { rte } from '../../../helpers/constants'; import {acceptLicenseTerms, deleteDatabase} from '../../../helpers/database'; -import { BrowserPage, AddRedisDatabasePage, MyRedisDatabasePage } from '../../../pageObjects'; +import { BrowserPage, AddRedisDatabasePage, MyRedisDatabasePage, DatabaseOverviewPage } from '../../../pageObjects'; import { commonUrl, cloudDatabaseConfig } from '../../../helpers/conf'; const browserPage = new BrowserPage(); const addRedisDatabasePage = new AddRedisDatabasePage(); const myRedisDatabasePage = new MyRedisDatabasePage(); +const databaseOverviewPage = new DatabaseOverviewPage(); fixture `Overview` .meta({ type: 'regression' }) @@ -27,18 +28,18 @@ fixture `Overview` .afterEach(async() => { //Delete database await deleteDatabase(cloudDatabaseConfig.databaseName); - }) -test - .meta({ rte: rte.standalone }) - ('Verify that user can see not available metrics from Overview in tooltip with the text " is/are not available"', async t => { + }); +// Skip due to prod cloud DBs issue https://redislabs.atlassian.net/browse/RI-2793 +test.skip + .meta({ rte: rte.standalone })('Verify that user can see not available metrics from Overview in tooltip with the text " is/are not available"', async t => { //Verify that CPU parameter is not displayed in Overview await t.expect(browserPage.overviewCpu.visible).notOk('Not available CPU'); //Click on More Info icon - await t.click(browserPage.overviewMoreInfo); + await t.click(databaseOverviewPage.overviewMoreInfo); //Check that tooltip was opened - await t.expect(browserPage.overviewTooltip.visible).ok('Overview tooltip'); + await t.expect(databaseOverviewPage.overviewTooltip.visible).ok('Overview tooltip'); //Verify that Database statistics title is displayed in tooltip - await t.expect(browserPage.overviewTooltipStatTitle.visible).ok('Statistics title'); + await t.expect(databaseOverviewPage.overviewTooltipStatTitle.visible).ok('Statistics title'); //Verify that CPU parameter is displayed in tooltip await t.expect(browserPage.overviewCpu.find('i').textContent).eql('CPU is not available'); }); diff --git a/tests/e2e/tests/regression/monitor/save-commands.e2e.ts b/tests/e2e/tests/regression/monitor/save-commands.e2e.ts new file mode 100644 index 0000000000..4e4d5928d9 --- /dev/null +++ b/tests/e2e/tests/regression/monitor/save-commands.e2e.ts @@ -0,0 +1,56 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import {acceptLicenseTermsAndAddDatabase, deleteDatabase} from '../../../helpers/database'; +import { MonitorPage } from '../../../pageObjects'; +import { + commonUrl, + ossStandaloneConfig +} from '../../../helpers/conf'; +import { rte } from '../../../helpers/constants'; + +const monitorPage = new MonitorPage(); +const tempDir = os.tmpdir(); + +fixture `Save commands` + .meta({ type: 'regression', rte: rte.standalone }) + .page(commonUrl) + .beforeEach(async() => { + await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); + }) + .afterEach(async() => { + //Delete database + await deleteDatabase(ossStandaloneConfig.databaseName); + }); +test('Verify that when clicks on “Reset Profiler” button he brought back to Profiler home screen', async t => { + //Start Monitor without Save logs + await monitorPage.startMonitor(); + //Remember the number of files in Temp + const numberOfTempFiles = fs.readdirSync(tempDir).length; + //Reset profiler + await monitorPage.resetProfiler(); + //Check the screen + await t.expect(monitorPage.monitorNotStartedElement.visible).ok('The Profiler home screen appears'); + await t.click(monitorPage.closeMonitor); + //Start Monitor with Save logs + await monitorPage.startMonitorWithSaveLog(); + //Reset profiler + await monitorPage.resetProfiler(); + //Check the screen + await t.expect(monitorPage.monitorNotStartedElement.visible).ok('The Profiler home screen appears'); + await t.expect(monitorPage.monitorIsStartedText.visible).notOk('The current Profiler session is closed'); + //temporary Log file is deleted + await t.expect(numberOfTempFiles).eql(fs.readdirSync(tempDir).length, 'The temporary Log file is deleted'); +}); +test('Verify that when user clears the Profiler he doesn\'t brought back to Profiler home screen', async t => { + //Start Monitor + await monitorPage.startMonitor(); + //Clear monitor and check the view + await t.click(monitorPage.clearMonitorButton); + await t.expect(monitorPage.monitorNotStartedElement.visible).notOk('Profiler home screen is not opened after Clear'); + await t.click(monitorPage.closeMonitor); + //Start Monitor with Save logs + await monitorPage.startMonitorWithSaveLog(); + //Clear monitor and check the view + await t.click(monitorPage.clearMonitorButton); + await t.expect(monitorPage.monitorNotStartedElement.visible).notOk('Profiler home screen is not opened after Clear'); +}); diff --git a/tests/e2e/tests/regression/tree-view/tree-view.e2e.ts b/tests/e2e/tests/regression/tree-view/tree-view.e2e.ts index af9ea3373c..7cf1ff3064 100644 --- a/tests/e2e/tests/regression/tree-view/tree-view.e2e.ts +++ b/tests/e2e/tests/regression/tree-view/tree-view.e2e.ts @@ -12,31 +12,29 @@ const browserPage = new BrowserPage(); fixture `Tree view verifications` .meta({type: 'regression'}) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTermsAndAddDatabase(ossStandaloneBigConfig, ossStandaloneBigConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Delete database await deleteDatabase(ossStandaloneBigConfig.databaseName); - }) + }); test .meta({ rte: rte.standalone }) - .before(async () => { + .before(async() => { await acceptLicenseTermsAndAddDatabase(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .after(async () => { + .after(async() => { //Delete database await deleteDatabase(ossStandaloneConfig.databaseName); - }) - ('Verify that user can see message "No keys to display." when there are no keys in the database', async t => { + })('Verify that user can see message "No keys to display." when there are no keys in the database', async t => { //Verify the message await t.click(browserPage.treeViewButton); await t.expect(browserPage.keyListTable.textContent).contains('No keys to display.', 'The message is displayed'); }); //skipped due the issue test.skip - .meta({ rte: rte.standalone }) - ('Verify that user can see the total number of keys, the number of keys scanned, the “Scan more” control displayed at the top of Tree view and Browser view', async t => { + .meta({ rte: rte.standalone })('Verify that user can see the total number of keys, the number of keys scanned, the “Scan more” control displayed at the top of Tree view and Browser view', async t => { //Verify the controls on the Browser view await t.expect(browserPage.totalKeysNumber.visible).ok('The total number of keys is displayed on the Browser view'); await t.expect(browserPage.scannedValue.visible).ok('The number of keys scanned is displayed on the Browser view'); @@ -48,11 +46,10 @@ test.skip await t.expect(browserPage.scanMoreButton.visible).ok('The scan more button is displayed on the Tree view'); }); test - .meta({ rte: rte.standalone }) - ('Verify that when user deletes the key he can see the key is removed from the folder, the number of keys is reduced, the percentage is recalculated', async t => { + .meta({ rte: rte.standalone })('Verify that when user deletes the key he can see the key is removed from the folder, the number of keys is reduced, the percentage is recalculated', async t => { //Open the first key in the tree view and remove await t.click(browserPage.treeViewButton); - await t.expect(browserPage.treeViewDeviceFolder.visible).ok('The key folder is displayed', { timeout: 60000 }); + await t.expect(browserPage.treeViewDeviceFolder.visible).ok('The key folder is displayed', { timeout: 30000 }); await t.click(browserPage.treeViewDeviceFolder); const numberOfKeys = await browserPage.treeViewDeviceKyesCount.textContent; const keyFolder = await browserPage.treeViewDeviceFolder.nth(2).textContent; @@ -64,8 +61,7 @@ test await t.expect(browserPage.treeViewDeviceKyesCount.textContent).notEql(numberOfKeys, 'The number of keys is recalculated'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see that “:” (colon) used as a default separator for namespaces and see the number of keys found per each namespace', async t => { + .meta({ rte: rte.standalone })('Verify that user can see that “:” (colon) used as a default separator for namespaces and see the number of keys found per each namespace', async t => { await t.click(browserPage.treeViewButton); //Verify the default separator await t.expect(browserPage.treeViewSeparator.textContent).eql(':', 'The “:” (colon) used as a default separator for namespaces'); diff --git a/tests/e2e/tests/regression/workbench/history-of-results.e2e.ts b/tests/e2e/tests/regression/workbench/history-of-results.e2e.ts index 76d84650d7..015add0b1f 100644 --- a/tests/e2e/tests/regression/workbench/history-of-results.e2e.ts +++ b/tests/e2e/tests/regression/workbench/history-of-results.e2e.ts @@ -1,9 +1,9 @@ +import { Chance } from 'chance'; import { getRandomParagraph } from '../../../helpers/keys'; import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; import { MyRedisDatabasePage, WorkbenchPage, CliPage } from '../../../pageObjects'; import { rte } from '../../../helpers/constants'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); @@ -12,7 +12,7 @@ const cliPage = new CliPage(); const oneMinuteTimeout = 60000; let keyName = chance.word({ length: 10 }); -let command = `set ${keyName} test`; +const command = `set ${keyName} test`; fixture `History of results at Workbench` .meta({type: 'regression'}) @@ -22,14 +22,13 @@ fixture `History of results at Workbench` //Go to Workbench page await t.click(myRedisDatabasePage.workbenchButton); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await cliPage.sendCommandInCli(`DEL ${keyName}`); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can see original date and time of command execution in Workbench history after the page update', async t => { + .meta({ rte: rte.standalone })('Verify that user can see original date and time of command execution in Workbench history after the page update', async t => { keyName = chance.word({ length: 5 }); //Send command and remember the time await workbenchPage.sendCommandInWorkbench(command); @@ -42,15 +41,14 @@ test //skipped due the long time execution and hangs of test test.skip .meta({ rte: rte.standalone }) - .after(async () => { + .after(async() => { //Delete database await deleteDatabase(ossStandaloneConfig.databaseName); - }) - ('Verify that if command result is more than 1 MB and user refreshes the page, the message "Results have been deleted since they exceed 1 MB. Re-run the command to see new results." is displayed', async t => { + })('Verify that if command result is more than 1 MB and user refreshes the page, the message "Results have been deleted since they exceed 1 MB. Re-run the command to see new results." is displayed', async t => { const commandToSend = 'set key'; const commandToGet = 'get key'; //Send command with value that exceed 1MB - let commandText = getRandomParagraph(10).repeat(100); + const commandText = getRandomParagraph(10).repeat(100); await workbenchPage.sendCommandInWorkbench(`${commandToSend} "${commandText}"`); await workbenchPage.sendCommandInWorkbench(commandToGet); //Refresh the page and check result @@ -59,8 +57,7 @@ test.skip await t.expect(workbenchPage.queryTextResult.textContent).eql('"Results have been deleted since they exceed 1 MB. Re-run the command to see new results."', 'The messageis displayed'); }); test - .meta({ rte: rte.standalone }) - ('Verify that the first command in workbench history is deleted when user executes 31 command (new the following result replaces the first result)', async t => { + .meta({ rte: rte.standalone })('Verify that the first command in workbench history is deleted when user executes 31 command (new the following result replaces the first result)', async t => { keyName = chance.word({ length: 10 }); const numberOfCommands = 30; const firstCommand = 'FT._LIST'; @@ -69,16 +66,15 @@ test await t.expect(workbenchPage.queryCardContainer.nth(0).textContent).contains(firstCommand, 'The first executed command is in the workbench history'); //Send 30 commands and check the results await workbenchPage.sendCommandInWorkbench(`${numberOfCommands} ${command}`); - for( let i = 0; i < numberOfCommands; i++) { + for(let i = 0; i < numberOfCommands; i++) { await t.expect(workbenchPage.queryCardContainer.nth(0).textContent).contains(command, 'The command executed after the first command is displayed'); - await t.expect(workbenchPage.queryCardContainer.nth(0).find(workbenchPage.cssQueryTextResult).visible).ok('The command executed after the first command is displayed', { timeout: 30000 }); + await t.expect(workbenchPage.queryCardContainer.nth(0).find(workbenchPage.cssQueryTextResult).visible).ok('The command executed after the first command is displayed', { timeout: 10000 }); await t.click(workbenchPage.queryCardContainer.nth(0).find(workbenchPage.cssDeleteCommandButton)); } await t.expect(workbenchPage.noCommandHistoryTitle.visible).ok('The first command is deleted when user executes 31 command'); }); test - .meta({ rte: rte.none }) - ('Verify that user can see cursor is at the first character when Editor is empty', async t => { + .meta({ rte: rte.none })('Verify that user can see cursor is at the first character when Editor is empty', async t => { const commands = [ 'FT.INFO', 'RANDOMKEY' @@ -92,6 +88,6 @@ test await t.typeText(workbenchPage.queryInput, commandForCheck); await t.pressKey('enter'); await t.pressKey('up'); - let script = await workbenchPage.scriptsLines.textContent; + const script = await workbenchPage.scriptsLines.textContent; await t.expect(script.replace(/\s/g, ' ')).contains(commandForCheck, 'The command is not changed'); - }) + }); diff --git a/tests/e2e/tests/regression/workbench/redis-stack-commands.e2e.ts b/tests/e2e/tests/regression/workbench/redis-stack-commands.e2e.ts index bb44dcc674..c3a9efb74e 100644 --- a/tests/e2e/tests/regression/workbench/redis-stack-commands.e2e.ts +++ b/tests/e2e/tests/regression/workbench/redis-stack-commands.e2e.ts @@ -1,12 +1,11 @@ +import { t } from 'testcafe'; import { acceptLicenseTermsAndAddDatabase, deleteDatabase } from '../../../helpers/database'; -import { WorkbenchPage } from '../../../pageObjects/workbench-page'; -import { MyRedisDatabasePage } from '../../../pageObjects'; +import { WorkbenchPage, MyRedisDatabasePage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; import { env, rte } from '../../../helpers/constants'; -import { t } from 'testcafe'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); @@ -24,11 +23,10 @@ fixture `Redis Stack command in Workbench` await t.switchToMainWindow(); await workbenchPage.sendCommandInWorkbench(`GRAPH.DELETE ${keyNameGraph}`); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); //skipped due the inaccessibility of the iframe test.skip - .meta({ rte: rte.standalone }) - ('Verify that user can switches between Graph and Text for GRAPH command and see results corresponding to their views', async t => { + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user can switches between Graph and Text for GRAPH command and see results corresponding to their views', async t => { //Send Graph command await t.click(workbenchPage.redisStackTutorialsButton); await t.click(workbenchPage.workingWithGraphLink); @@ -42,10 +40,8 @@ test.skip await t.switchToIframe(workbenchPage.iframe); await t.expect(await workbenchPage.queryCardContainer.nth(0).find(workbenchPage.queryGraphContainer).exists).ok('The Graph view is switched for GRAPH command'); }); -//skipped due the inaccessibility of the iframe -test.skip - .meta({ rte: rte.standalone }) - ('Verify that user can see "No data to visualize" message for Graph command', async t => { +test + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user can see "No data to visualize" message for Graph command', async t => { //Send Graph command await t.click(workbenchPage.redisStackTutorialsButton); await t.click(workbenchPage.workingWithGraphLink); @@ -60,8 +56,7 @@ test.skip await t.expect(workbenchPage.queryTextResult.exists).ok('The result in text view is displayed'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can switches between Chart and Text for TimeSeries command and see results corresponding to their views', async t => { + .meta({ rte: rte.standalone })('Verify that user can switches between Chart and Text for TimeSeries command and see results corresponding to their views', async t => { //Send TimeSeries command await t.click(workbenchPage.redisStackTutorialsButton); await t.click(workbenchPage.timeSeriesLink); diff --git a/tests/e2e/tests/smoke/browser/add-keys.e2e.ts b/tests/e2e/tests/smoke/browser/add-keys.e2e.ts index d9888f0085..1fb1f3a373 100644 --- a/tests/e2e/tests/smoke/browser/add-keys.e2e.ts +++ b/tests/e2e/tests/smoke/browser/add-keys.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { rte } from '../../../helpers/constants'; import { deleteDatabase, acceptTermsAddDatabaseOrConnectToRedisStack } from '../../../helpers/database'; import { BrowserPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const browserPage = new BrowserPage(); const chance = new Chance(); @@ -97,7 +97,7 @@ test const keyTTL = '2147476121'; const value = '{"name":"xyz"}'; //add JSON key - await browserPage.addJsonKey(keyName, keyTTL, value); + await browserPage.addJsonKey(keyName, value, keyTTL); //check the notification message const notofication = await browserPage.getMessageText(); await t.expect(notofication).contains('Key has been added', 'The notification'); diff --git a/tests/e2e/tests/smoke/browser/edit-key-name.e2e.ts b/tests/e2e/tests/smoke/browser/edit-key-name.e2e.ts index fea808df91..07b28676b6 100644 --- a/tests/e2e/tests/smoke/browser/edit-key-name.e2e.ts +++ b/tests/e2e/tests/smoke/browser/edit-key-name.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { rte } from '../../../helpers/constants'; import { deleteDatabase, acceptTermsAddDatabaseOrConnectToRedisStack } from '../../../helpers/database'; import { BrowserPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const browserPage = new BrowserPage(); const chance = new Chance(); @@ -13,10 +13,10 @@ let keyNameAfter = chance.word({ length: 10 }); fixture `Edit Key names verification` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptTermsAddDatabaseOrConnectToRedisStack(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await browserPage.deleteKeyByName(keyNameAfter); await deleteDatabase(ossStandaloneConfig.databaseName); @@ -99,7 +99,7 @@ test const keyTTL = '2147476121'; const keyValue = '{"name":"xyz"}'; - await browserPage.addJsonKey(keyNameBefore, keyTTL, keyValue); + await browserPage.addJsonKey(keyNameBefore, keyValue, keyTTL); let keyNameFromDetails = await browserPage.keyNameFormDetails.textContent; await t.expect(keyNameFromDetails).contains(keyNameBefore, 'The Key Name'); await browserPage.editKeyName(keyNameAfter); diff --git a/tests/e2e/tests/smoke/browser/hash-field.e2e.ts b/tests/e2e/tests/smoke/browser/hash-field.e2e.ts index 27719ea68b..aea5748136 100644 --- a/tests/e2e/tests/smoke/browser/hash-field.e2e.ts +++ b/tests/e2e/tests/smoke/browser/hash-field.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { rte } from '../../../helpers/constants'; import { deleteDatabase, acceptTermsAddDatabaseOrConnectToRedisStack } from '../../../helpers/database'; -import { BrowserPage, AddRedisDatabasePage } from '../../../pageObjects'; +import { BrowserPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const browserPage = new BrowserPage(); const chance = new Chance(); @@ -15,17 +15,16 @@ const keyValue = 'hashValue11111!'; fixture `Hash Key fields verification` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptTermsAddDatabaseOrConnectToRedisStack(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await browserPage.deleteKeyByName(keyName); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can add field to Hash', async t => { + .meta({ rte: rte.standalone })('Verify that user can add field to Hash', async t => { keyName = chance.word({ length: 10 }); await browserPage.addHashKey(keyName, keyTTL); //Add field to the hash key @@ -33,12 +32,11 @@ test //Search the added field await browserPage.searchByTheValueInKeyDetails(keyFieldValue); //Check the added field - await t.expect(browserPage.hashValuesList.withExactText(keyValue).exists).ok('The existence of the value', { timeout: 20000 }); - await t.expect(browserPage.hashFieldsList.withExactText(keyFieldValue).exists).ok('The existence of the field', { timeout: 20000 }); + await t.expect(browserPage.hashValuesList.withExactText(keyValue).exists).ok('The existence of the value', { timeout: 10000 }); + await t.expect(browserPage.hashFieldsList.withExactText(keyFieldValue).exists).ok('The existence of the field', { timeout: 10000 }); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can remove field from Hash', async t => { + .meta({ rte: rte.standalone })('Verify that user can remove field from Hash', async t => { keyName = chance.word({ length: 10 }); await browserPage.addHashKey(keyName, keyTTL); //Add field to the hash key diff --git a/tests/e2e/tests/smoke/browser/json-key.e2e.ts b/tests/e2e/tests/smoke/browser/json-key.e2e.ts index 430710e022..756c09dacf 100644 --- a/tests/e2e/tests/smoke/browser/json-key.e2e.ts +++ b/tests/e2e/tests/smoke/browser/json-key.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { rte } from '../../../helpers/constants'; import { deleteDatabase, acceptTermsAddDatabaseOrConnectToRedisStack } from '../../../helpers/database'; import { BrowserPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const browserPage = new BrowserPage(); const chance = new Chance(); @@ -15,40 +15,39 @@ const jsonObjectValue = '{name:"xyz"}'; fixture `JSON Key verification` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptTermsAddDatabaseOrConnectToRedisStack(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await browserPage.deleteKeyByName(keyName); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can create JSON object', async t => { + .meta({ rte: rte.standalone })('Verify that user can create JSON object', async t => { keyName = chance.word({ length: 10 }); //Add Json key with json object - await browserPage.addJsonKey(keyName, keyTTL, value); + await browserPage.addJsonKey(keyName, value, keyTTL); //Check the notification message - const notofication = await browserPage.getMessageText(); - await t.expect(notofication).contains('Key has been added', 'The notification'); + const notification = await browserPage.getMessageText(); + await t.expect(notification).contains('Key has been added', 'The notification'); //Check the added key contains json object - await t.expect(browserPage.addJsonObjectButton.exists).ok('The existence of the add Json object button', { timeout: 20000 }); + await t.expect(browserPage.addJsonObjectButton.exists).ok('The existence of the add Json object button', { timeout: 10000 }); await t.expect(browserPage.jsonKeyValue.textContent).eql(jsonObjectValue, 'The json object value'); }); -test - .meta({ rte: rte.standalone }) - ('Verify that user can add key with value to any level of JSON structure', async t => { +//skipped due the issue https://redislabs.atlassian.net/browse/RI-2866 +test.skip + .meta({ rte: rte.standalone })('Verify that user can add key with value to any level of JSON structure', async t => { keyName = chance.word({ length: 10 }); //Add Json key with json object - await browserPage.addJsonKey(keyName, keyTTL, value); + await browserPage.addJsonKey(keyName, value, keyTTL); //Check the notification message const notofication = await browserPage.getMessageText(); await t.expect(notofication).contains('Key has been added', 'The notification'); //Add key with value on the same level await browserPage.addJsonKeyOnTheSameLevel('"key1"', '"value1"'); //Check the added key contains json object with added key - await t.expect(browserPage.addJsonObjectButton.exists).ok('The existence of the add Json object button', { timeout: 20000 }); + await t.expect(browserPage.addJsonObjectButton.exists).ok('The existence of the add Json object button', { timeout: 10000 }); await t.expect(browserPage.jsonKeyValue.textContent).eql('{name:"xyz"key1:"value1"}', 'The json object value'); //Add key with value inside the json await browserPage.addJsonKeyOnTheSameLevel('"key2"', '{}'); diff --git a/tests/e2e/tests/smoke/browser/list-key.e2e.ts b/tests/e2e/tests/smoke/browser/list-key.e2e.ts index c3d0932cbc..1a459f2574 100644 --- a/tests/e2e/tests/smoke/browser/list-key.e2e.ts +++ b/tests/e2e/tests/smoke/browser/list-key.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { rte } from '../../../helpers/constants'; import { deleteDatabase, acceptTermsAddDatabaseOrConnectToRedisStack } from '../../../helpers/database'; import { BrowserPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const browserPage = new BrowserPage(); const chance = new Chance(); @@ -16,27 +16,25 @@ const element3 = '33333listElement33333'; fixture `List Key verification` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptTermsAddDatabaseOrConnectToRedisStack(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await browserPage.deleteKeyByName(keyName); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can add element to List', async t => { + .meta({ rte: rte.standalone })('Verify that user can add element to List', async t => { keyName = chance.word({ length: 10 }); await browserPage.addListKey(keyName, keyTTL); //Add element to the List key await browserPage.addElementToList(element); //Check the added element - await t.expect(browserPage.listElementsList.withExactText(element).exists).ok('The existence of the list element', { timeout: 20000 }); + await t.expect(browserPage.listElementsList.withExactText(element).exists).ok('The existence of the list element', { timeout: 10000 }); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can select remove List element position: from tail', async t => { + .meta({ rte: rte.standalone })('Verify that user can select remove List element position: from tail', async t => { keyName = chance.word({ length: 10 }); await browserPage.addListKey(keyName, keyTTL); //Add few elements to the List key @@ -46,14 +44,13 @@ test //Remove element from the key await browserPage.removeListElementFromTail('1'); //Check the notification message - const notofication = await browserPage.getMessageText(); - await t.expect(notofication).contains('Elements have been removed', 'The notification'); + const notification = await browserPage.getMessageText(); + await t.expect(notification).contains('Elements have been removed', 'The notification'); //Check the removed element is not in the list - await t.expect(browserPage.listElementsList.withExactText(element3).exists).notOk('The removing of the list element', { timeout: 20000 }); + await t.expect(browserPage.listElementsList.withExactText(element3).exists).notOk('The removing of the list element', { timeout: 10000 }); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can select remove List element position: from head', async t => { + .meta({ rte: rte.standalone })('Verify that user can select remove List element position: from head', async t => { keyName = chance.word({ length: 10 }); await browserPage.addListKey(keyName, keyTTL, element); //Add few elements to the List key @@ -65,5 +62,5 @@ test const notofication = await browserPage.getMessageText(); await t.expect(notofication).contains('Elements have been removed', 'The notification'); //Check the removed element is not in the list - await t.expect(browserPage.listElementsList.withExactText(element).exists).notOk('The removing of the list element', { timeout: 20000 }); + await t.expect(browserPage.listElementsList.withExactText(element).exists).notOk('The removing of the list element', { timeout: 10000 }); }); diff --git a/tests/e2e/tests/smoke/browser/set-key.e2e.ts b/tests/e2e/tests/smoke/browser/set-key.e2e.ts index 7003f943f5..cc27c2f277 100644 --- a/tests/e2e/tests/smoke/browser/set-key.e2e.ts +++ b/tests/e2e/tests/smoke/browser/set-key.e2e.ts @@ -1,6 +1,6 @@ +import { Chance } from 'chance'; import { acceptTermsAddDatabaseOrConnectToRedisStack, deleteDatabase } from '../../../helpers/database'; import { BrowserPage } from '../../../pageObjects'; -import { Chance } from 'chance'; import { commonUrl, ossStandaloneConfig @@ -17,27 +17,25 @@ const keyMember = '1111setMember11111'; fixture `Set Key fields verification` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptTermsAddDatabaseOrConnectToRedisStack(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await browserPage.deleteKeyByName(keyName); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can add member to Set', async t => { + .meta({ rte: rte.standalone })('Verify that user can add member to Set', async t => { keyName = chance.word({ length: 10 }); await browserPage.addSetKey(keyName, keyTTL); //Add member to the Set key await browserPage.addMemberToSet(keyMember); //Check the added member - await t.expect(browserPage.setMembersList.withExactText(keyMember).exists).ok('The existence of the set member', { timeout: 20000 }); + await t.expect(browserPage.setMembersList.withExactText(keyMember).exists).ok('The existence of the set member', { timeout: 10000 }); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can remove member from Set', async t => { + .meta({ rte: rte.standalone })('Verify that user can remove member from Set', async t => { keyName = chance.word({ length: 10 }); await browserPage.addSetKey(keyName, keyTTL); //Add member to the Set key @@ -46,6 +44,6 @@ test await t.click(browserPage.removeSetMemberButton); await t.click(browserPage.confirmRemoveSetMemberButton); //Check the notification message - const notofication = await browserPage.getMessageText(); - await t.expect(notofication).contains('Member has been removed', 'The notification'); + const notification = await browserPage.getMessageText(); + await t.expect(notification).contains('Member has been removed', 'The notification'); }); diff --git a/tests/e2e/tests/smoke/browser/verify-key-details.e2e.ts b/tests/e2e/tests/smoke/browser/verify-key-details.e2e.ts index c193a66f2d..a34bd5c211 100644 --- a/tests/e2e/tests/smoke/browser/verify-key-details.e2e.ts +++ b/tests/e2e/tests/smoke/browser/verify-key-details.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { rte } from '../../../helpers/constants'; import { acceptTermsAddDatabaseOrConnectToRedisStack, deleteDatabase } from '../../../helpers/database'; import { BrowserPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const browserPage = new BrowserPage(); const chance = new Chance(); @@ -14,10 +14,10 @@ const expectedTTL = /214747612*/; fixture `Key details verification` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptTermsAddDatabaseOrConnectToRedisStack(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await browserPage.deleteKeyByName(keyName); await deleteDatabase(ossStandaloneConfig.databaseName); @@ -115,7 +115,7 @@ test const jsonValue = '{"employee":{ "name":"John", "age":30, "city":"New York" }}'; - await browserPage.addJsonKey(keyName, keyTTL, jsonValue); + await browserPage.addJsonKey(keyName, jsonValue, keyTTL); const keyDetails = await browserPage.keyDetailsHeader.textContent; const keyBadge = await browserPage.keyDetailsBadge.textContent; const keyNameFromDetails = await browserPage.keyNameFormDetails.textContent; @@ -127,4 +127,3 @@ test await t.expect(keyTTLValue).match(expectedTTL, 'The Key TTL'); await t.expect(keyBadge).contains('JSON', 'The Key Badge'); }); - \ No newline at end of file diff --git a/tests/e2e/tests/smoke/browser/verify-keys-refresh.e2e.ts b/tests/e2e/tests/smoke/browser/verify-keys-refresh.e2e.ts index 59d74b12af..81cbbd119d 100644 --- a/tests/e2e/tests/smoke/browser/verify-keys-refresh.e2e.ts +++ b/tests/e2e/tests/smoke/browser/verify-keys-refresh.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { rte } from '../../../helpers/constants'; import { acceptTermsAddDatabaseOrConnectToRedisStack, deleteDatabase } from '../../../helpers/database'; import { BrowserPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const browserPage = new BrowserPage(); const chance = new Chance(); @@ -13,34 +13,33 @@ let newKeyName = chance.word({ length: 10 }); fixture `Keys refresh functionality` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptTermsAddDatabaseOrConnectToRedisStack(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await browserPage.deleteKeyByName(newKeyName); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can refresh Keys', async t => { + .meta({ rte: rte.standalone })('Verify that user can refresh Keys', async t => { keyName = chance.word({ length: 10 }); const keyTTL = '2147476121'; newKeyName = chance.word({ length: 10 }); //add hash key await browserPage.addHashKey(keyName, keyTTL); - const notofication = await browserPage.getMessageText(); - await t.expect(notofication).contains('Key has been added', 'The notification'); + const notification = await browserPage.getMessageText(); + await t.expect(notification).contains('Key has been added', 'The notification'); await t.click(browserPage.closeKeyButton); //search for the added key await browserPage.searchByKeyName(keyName); - await t.expect(browserPage.keyNameInTheList.withExactText(keyName).exists).ok('The key is in the list', { timeout: 20000 }); + await t.expect(browserPage.keyNameInTheList.withExactText(keyName).exists).ok('The key is in the list', { timeout: 10000 }); //edit the key name await t.click(browserPage.keyNameInTheList); await browserPage.editKeyName(newKeyName); //refresh Keys and check await t.click(browserPage.refreshKeysButton); await browserPage.searchByKeyName(keyName); - await t.expect(browserPage.keyNameInTheList.withExactText(keyName).exists).notOk('The key is not in the list', { timeout: 20000 }); -}); + await t.expect(browserPage.keyNameInTheList.withExactText(keyName).exists).notOk('The key is not in the list', { timeout: 10000 }); + }); diff --git a/tests/e2e/tests/smoke/browser/zset-key.e2e.ts b/tests/e2e/tests/smoke/browser/zset-key.e2e.ts index 9b0404f0ff..435625827b 100644 --- a/tests/e2e/tests/smoke/browser/zset-key.e2e.ts +++ b/tests/e2e/tests/smoke/browser/zset-key.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { rte } from '../../../helpers/constants'; import { acceptTermsAddDatabaseOrConnectToRedisStack, deleteDatabase } from '../../../helpers/database'; import { BrowserPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const browserPage = new BrowserPage(); const chance = new Chance(); @@ -15,28 +15,26 @@ const score = '0'; fixture `ZSet Key fields verification` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptTermsAddDatabaseOrConnectToRedisStack(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Clear and delete database await browserPage.deleteKeyByName(keyName); await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can add members to Zset', async t => { + .meta({ rte: rte.standalone })('Verify that user can add members to Zset', async t => { keyName = chance.word({ length: 10 }); await browserPage.addZSetKey(keyName, '5', keyTTL); //Add member to the ZSet key await browserPage.addMemberToZSet(keyMember, score); //Check the added member - await t.expect(browserPage.zsetMembersList.withExactText(keyMember).exists).ok('The existence of the Zset member', { timeout: 20000 }); - await t.expect(browserPage.zsetScoresList.withExactText(score).exists).ok('The existence of the Zset score', { timeout: 20000 }); + await t.expect(browserPage.zsetMembersList.withExactText(keyMember).exists).ok('The existence of the Zset member', { timeout: 10000 }); + await t.expect(browserPage.zsetScoresList.withExactText(score).exists).ok('The existence of the Zset score', { timeout: 10000 }); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can remove member from ZSet', async t => { + .meta({ rte: rte.standalone })('Verify that user can remove member from ZSet', async t => { keyName = chance.word({ length: 10 }); await browserPage.addZSetKey(keyName, '6', keyTTL); //Add member to the ZSet key diff --git a/tests/e2e/tests/smoke/cli/cli.e2e.ts b/tests/e2e/tests/smoke/cli/cli.e2e.ts index 0e361dbaea..36516b4128 100644 --- a/tests/e2e/tests/smoke/cli/cli.e2e.ts +++ b/tests/e2e/tests/smoke/cli/cli.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { env, rte } from '../../../helpers/constants'; import { acceptTermsAddDatabaseOrConnectToRedisStack, deleteDatabase } from '../../../helpers/database'; import { MyRedisDatabasePage, BrowserPage, CliPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; -import { Chance } from 'chance'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); @@ -14,20 +14,19 @@ let keyName = chance.word({ length: 10 }); fixture `CLI` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptTermsAddDatabaseOrConnectToRedisStack(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async () => { + .afterEach(async() => { //Delete database await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test .meta({ rte: rte.standalone }) .after(async() => { await browserPage.deleteKeyByName(keyName); await deleteDatabase(ossStandaloneConfig.databaseName); - }) - ('Verify that user can add data via CLI', async t => { + })('Verify that user can add data via CLI', async t => { keyName = chance.word({ length: 10 }); //Open CLI await t.click(cliPage.cliExpandButton); @@ -40,17 +39,15 @@ test await t.expect(isKeyIsDisplayedInTheList).ok('The key is added'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can expand CLI', async t => { + .meta({ rte: rte.standalone })('Verify that user can expand CLI', async t => { //Open CLI await t.click(cliPage.cliExpandButton); //Check that CLI is opened await t.expect(cliPage.cliArea.exists).ok('CLI area is displayed'); - await t.expect(cliPage.cliCommandInput.exists).ok('CLI input is displayed') + await t.expect(cliPage.cliCommandInput.exists).ok('CLI input is displayed'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can collapse CLI', async t => { + .meta({ rte: rte.standalone })('Verify that user can collapse CLI', async t => { //Open CLI await t.click(cliPage.cliExpandButton); //Check that CLI is opened @@ -61,8 +58,7 @@ test await t.expect(cliPage.cliArea.visible).notOk('CLI area should not be displayed'); }); test - .meta({ rte: rte.standalone }) - ('Verify that user can use blocking command', async t => { + .meta({ rte: rte.standalone })('Verify that user can use blocking command', async t => { //Open CLI await t.click(cliPage.cliExpandButton); //Type blocking command @@ -72,8 +68,7 @@ test await t.expect(cliPage.cliCommandInput.exists).notOk('Cli input is not shown'); }); test - .meta({ env: env.web, rte: rte.standalone }) - ('Verify that user can use unblocking command', async t => { + .meta({ env: env.web, rte: rte.standalone })('Verify that user can use unblocking command', async t => { //Open CLI await t.click(cliPage.cliExpandButton); //Get clientId @@ -96,5 +91,5 @@ test await t.typeText(cliPage.cliCommandInput, `client unblock ${clientId}`); await t.pressKey('enter'); await t.closeWindow(); - await t.expect(cliPage.cliCommandInput.exists).ok('Cli input is shown, the client was unblocked', { timeout: 20000 }); + await t.expect(cliPage.cliCommandInput.exists).ok('Cli input is shown, the client was unblocked', { timeout: 10000 }); }); diff --git a/tests/e2e/tests/smoke/database/add-db-from-welcome-page.e2e.ts b/tests/e2e/tests/smoke/database/add-db-from-welcome-page.e2e.ts index c08786bdea..3579afefee 100644 --- a/tests/e2e/tests/smoke/database/add-db-from-welcome-page.e2e.ts +++ b/tests/e2e/tests/smoke/database/add-db-from-welcome-page.e2e.ts @@ -9,20 +9,19 @@ const addRedisDatabasePage = new AddRedisDatabasePage(); fixture `Add database from welcome page` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTerms(); }) - .afterEach(async () => { + .afterEach(async() => { //Delete database await deleteDatabase(ossStandaloneConfig.databaseName); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can add first DB from Welcome page', async t => { + .meta({ rte: rte.standalone })('Verify that user can add first DB from Welcome page', async t => { //Delete all the databases to open Welcome page await myRedisDatabasePage.deleteAllDatabases(); await t.expect(addRedisDatabasePage.welcomePageTitle.exists).ok('The welcome page title'); //Add database from Welcome page await addNewStandaloneDatabase(ossStandaloneConfig); - await t.expect(myRedisDatabasePage.dbNameList.withExactText(ossStandaloneConfig.databaseName).exists).ok('The database adding', { timeout: 60000 }); + await t.expect(myRedisDatabasePage.dbNameList.withExactText(ossStandaloneConfig.databaseName).exists).ok('The database adding', { timeout: 10000 }); }); diff --git a/tests/e2e/tests/smoke/database/delete-the-db.e2e.ts b/tests/e2e/tests/smoke/database/delete-the-db.e2e.ts index d96f9cd583..18a2813005 100644 --- a/tests/e2e/tests/smoke/database/delete-the-db.e2e.ts +++ b/tests/e2e/tests/smoke/database/delete-the-db.e2e.ts @@ -1,6 +1,5 @@ -import { addNewStandaloneDatabase } from '../../../helpers/database'; +import { addNewStandaloneDatabase, acceptLicenseTerms } from '../../../helpers/database'; import { rte } from '../../../helpers/constants'; -import { acceptLicenseTerms } from '../../../helpers/database'; import { MyRedisDatabasePage } from '../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; @@ -9,13 +8,12 @@ const myRedisDatabasePage = new MyRedisDatabasePage(); fixture `Delete database` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTerms(); - }) + }); test - .meta({ rte: rte.standalone }) - ('Verify that user can delete databases', async t => { + .meta({ rte: rte.standalone })('Verify that user can delete databases', async t => { await addNewStandaloneDatabase(ossStandaloneConfig); await myRedisDatabasePage.deleteDatabaseByName(ossStandaloneConfig.databaseName); - await t.expect(myRedisDatabasePage.dbNameList.withExactText(ossStandaloneConfig.databaseName).exists).notOk('The database deletion', { timeout: 60000 }); + await t.expect(myRedisDatabasePage.dbNameList.withExactText(ossStandaloneConfig.databaseName).exists).notOk('The database deletion', { timeout: 10000 }); }); diff --git a/tests/e2e/tests/smoke/database/edit-db.e2e.ts b/tests/e2e/tests/smoke/database/edit-db.e2e.ts index 6c92ba268d..5d878e8878 100644 --- a/tests/e2e/tests/smoke/database/edit-db.e2e.ts +++ b/tests/e2e/tests/smoke/database/edit-db.e2e.ts @@ -1,6 +1,5 @@ import { ClientFunction } from 'testcafe'; -import { acceptLicenseTerms, deleteDatabase } from '../../../helpers/database'; -import { addNewStandaloneDatabase, addNewREClusterDatabase, addNewRECloudDatabase } from '../../../helpers/database'; +import { acceptLicenseTerms, deleteDatabase, addNewStandaloneDatabase, addNewREClusterDatabase, addNewRECloudDatabase } from '../../../helpers/database'; import { MyRedisDatabasePage, UserAgreementPage, AddRedisDatabasePage } from '../../../pageObjects'; import { commonUrl, @@ -16,41 +15,38 @@ const addRedisDatabasePage = new AddRedisDatabasePage(); fixture `Edit Databases` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTerms(); - }) + }); //Returns the URL of the current web page const getPageUrl = ClientFunction(() => window.location.href); test .meta({ rte: rte.reCluster }) - .after(async () => { + .after(async() => { //Delete database await deleteDatabase(redisEnterpriseClusterConfig.databaseName); - }) - ('Verify that user can connect to the RE cluster database', async t => { + })('Verify that user can connect to the RE cluster database', async t => { await addNewREClusterDatabase(redisEnterpriseClusterConfig); await myRedisDatabasePage.clickOnDBByName(redisEnterpriseClusterConfig.databaseName); await t.expect(getPageUrl()).contains('browser', 'The edit view is opened'); }); test .meta({ rte: rte.standalone }) - .after(async () => { + .after(async() => { //Delete database await deleteDatabase(ossStandaloneConfig.databaseName); - }) - ('Verify that user open edit view of database', async t => { + })('Verify that user open edit view of database', async t => { await userAgreementPage.acceptLicenseTerms(); - await t.expect(addRedisDatabasePage.addDatabaseButton.exists).ok('The add redis database view', { timeout: 20000 }); + await t.expect(addRedisDatabasePage.addDatabaseButton.exists).ok('The add redis database view', { timeout: 10000 }); await addNewStandaloneDatabase(ossStandaloneConfig); await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); await t.expect(getPageUrl()).contains('browser'); }); //skiped until the RE Cloud connection is implemented test.skip - .meta({ rte: rte.reCloud }) - ('Verify that user can connect to the RE Cloud database', async t => { + .meta({ rte: rte.reCloud })('Verify that user can connect to the RE Cloud database', async t => { //TODO: add api keys from env - const databaseName = await addNewRECloudDatabase('', ''); - await myRedisDatabasePage.clickOnDBByName(databaseName); - await t.expect(getPageUrl()).contains('browser', 'The edit view is opened'); + const databaseName = await addNewRECloudDatabase('', ''); + await myRedisDatabasePage.clickOnDBByName(databaseName); + await t.expect(getPageUrl()).contains('browser', 'The edit view is opened'); }); diff --git a/tests/e2e/tests/smoke/workbench/json-workbench.e2e.ts b/tests/e2e/tests/smoke/workbench/json-workbench.e2e.ts index 71c43c4c17..3ca5d27407 100644 --- a/tests/e2e/tests/smoke/workbench/json-workbench.e2e.ts +++ b/tests/e2e/tests/smoke/workbench/json-workbench.e2e.ts @@ -1,8 +1,8 @@ +import { Chance } from 'chance'; import { env, rte } from '../../../helpers/constants'; import { acceptTermsAddDatabaseOrConnectToRedisStack, deleteDatabase } from '../../../helpers/database'; import { MyRedisDatabasePage, WorkbenchPage } from '../../../pageObjects'; import { commonUrl, ossStandaloneRedisearch } from '../../../helpers/conf'; -import { Chance } from 'chance'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); @@ -18,15 +18,14 @@ fixture `JSON verifications at Workbench` //Go to Workbench page await t.click(myRedisDatabasePage.workbenchButton); }) - .afterEach(async () => { + .afterEach(async t => { //Clear and delete database + await t.switchToMainWindow(); await workbenchPage.sendCommandInWorkbench(`FT.DROPINDEX ${indexName} DD`); await deleteDatabase(ossStandaloneRedisearch.databaseName); - }) -//skipped due the inaccessibility of the iframe -test.skip - .meta({env: env.web, rte: rte.standalone }) - ('Verify that user can execute redisearch command for JSON data type in Workbench', async t => { + }); +test + .meta({ env: env.desktop, rte: rte.standalone })('Verify that user can execute redisearch command for JSON data type in Workbench', async t => { indexName = chance.word({ length: 10 }); const commandsForSend = [ `FT.CREATE ${indexName} ON JSON SCHEMA $.title AS title TEXT`, @@ -42,5 +41,6 @@ test.skip //Send search command to find JSON document await workbenchPage.sendCommandInWorkbench(searchCommand); //Verify that the search command is executed - await t.expect((await workbenchPage.getCardContainerByCommand(searchCommand)).textContent).contains('{"title":"foo","content":"bar"}', `The ${searchCommand} command is executed`); + await t.switchToIframe(workbenchPage.iframe); + await t.expect(workbenchPage.queryColumns.nth(1).textContent).contains('{\\"title\\":\\"foo\\",\\"content\\":\\"bar\\"}', `The ${searchCommand} command is executed`); }); diff --git a/tests/e2e/web.runner.ts b/tests/e2e/web.runner.ts index 1aeae8d820..65a710fc1d 100644 --- a/tests/e2e/web.runner.ts +++ b/tests/e2e/web.runner.ts @@ -29,8 +29,8 @@ import testcafe from 'testcafe'; quarantineMode: { successThreshold: '1', attemptLimit: '3' } }); }) - .then(() => { - process.exit(0); + .then((failedCount) => { + process.exit(failedCount); }) .catch((e) => { console.error(e)