diff --git a/.circleci/config.yml b/.circleci/config.yml index 92de323c2b..0cebfc7093 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -298,6 +298,7 @@ jobs: - checkout - attach_workspace: at: . + - run: sudo apt-get install net-tools - run: name: .AppImage tests command: | @@ -710,7 +711,7 @@ jobs: command: | applicationVersion=$(jq -r '.version' electron/package.json) echo "APP VERSION $applicationVersion" - ghr -t ${GH_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} -prerelease -delete ${applicationVersion} + ghr -n ${applicationVersion} -t ${GH_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} -prerelease -delete ${applicationVersion} release-aws-test: executor: linux-executor @@ -755,11 +756,13 @@ jobs: latestYmlFileName="latest.yml" downloadLatestFolderPath="public/latest" upgradeLatestFolderPath="public/upgrades" + releasesFolderPath="public/releases" appName=$(jq -r '.productName' electron-builder.json) appVersion=$(jq -r '.version' redisinsight/package.json) echo "export downloadLatestFolderPath=${downloadLatestFolderPath}" >> $BASH_ENV echo "export upgradeLatestFolderPath=${upgradeLatestFolderPath}" >> $BASH_ENV + echo "export releasesFolderPath=${releasesFolderPath}" >> $BASH_ENV echo "export applicationName=${appName}" >> $BASH_ENV echo "export applicationVersion=${appVersion}" >> $BASH_ENV echo "export appFileName=RedisInsight" >> $BASH_ENV @@ -776,22 +779,24 @@ jobs: - run: name: Publish AWS S3 command: | - # move last public version apps for download to /private/{last public version} - aws s3 mv s3://${AWS_BUCKET_NAME}/${downloadLatestFolderPath} \ - s3://${AWS_BUCKET_NAME}/private/${previousApplicationVersion}/ --recursive + # remove previous build from the latest directory /public/latest + aws s3 rm s3://${AWS_BUCKET_NAME}/${downloadLatestFolderPath} --recursive - # move last public version apps for upgrades to /private/{last public version} - aws s3 mv s3://${AWS_BUCKET_NAME}/${upgradeLatestFolderPath} \ - s3://${AWS_BUCKET_NAME}/private/${previousApplicationVersion}/ --recursive + # remove previous build from the upgrade directory /public/upgrades + aws s3 rm s3://${AWS_BUCKET_NAME}/${upgradeLatestFolderPath} --recursive - # move current version apps for download to /public/latest + # copy current version apps for download to /public/latest aws s3 cp s3://${AWS_BUCKET_NAME}/private/${applicationVersion}/ \ s3://${AWS_BUCKET_NAME}/${downloadLatestFolderPath} --recursive --exclude "*.zip" # copy current version apps for upgrades to /public/upgrades - aws s3 mv s3://${AWS_BUCKET_NAME}/private/${applicationVersion}/ \ + aws s3 cp s3://${AWS_BUCKET_NAME}/private/${applicationVersion}/ \ s3://${AWS_BUCKET_NAME}/${upgradeLatestFolderPath} --recursive + # !MOVE current version apps to releases folder /public/releases + aws s3 mv s3://${AWS_BUCKET_NAME}/private/${applicationVersion}/ \ + s3://${AWS_BUCKET_NAME}/${releasesFolderPath}/${applicationVersion} --recursive + # invalidate cloudfront cash aws cloudfront create-invalidation --distribution-id ${AWS_DISTRIBUTION_ID} --paths "/*" diff --git a/.github/redisinsight_browser.png b/.github/redisinsight_browser.png index f64610d83d..ef0d8ee738 100644 Binary files a/.github/redisinsight_browser.png and b/.github/redisinsight_browser.png differ diff --git a/package.json b/package.json index 6384a0ae06..e802541fd4 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,6 @@ "opencollective-postinstall": "^2.0.3", "react-hot-loader": "^4.13.0", "react-refresh": "^0.9.0", - "react-test-renderer": "^17.0.1", "redux-mock-store": "^1.5.4", "regenerator-runtime": "^0.13.5", "rimraf": "^3.0.2", diff --git a/redisinsight/api/src/__mocks__/analytics.ts b/redisinsight/api/src/__mocks__/analytics.ts index e1060eb1b3..2c86f04b6f 100644 --- a/redisinsight/api/src/__mocks__/analytics.ts +++ b/redisinsight/api/src/__mocks__/analytics.ts @@ -27,3 +27,9 @@ export const mockSettingsAnalyticsService = () => ({ sendAnalyticsAgreementChange: jest.fn(), sendSettingsUpdatedEvent: jest.fn(), }); + +export const mockPubSubAnalyticsService = () => ({ + sendMessagePublishedEvent: jest.fn(), + sendChannelSubscribeEvent: jest.fn(), + sendChannelUnsubscribeEvent: jest.fn(), +}); diff --git a/redisinsight/api/src/__mocks__/redis-databases.ts b/redisinsight/api/src/__mocks__/redis-databases.ts index e29d85c709..f28bd7f31d 100644 --- a/redisinsight/api/src/__mocks__/redis-databases.ts +++ b/redisinsight/api/src/__mocks__/redis-databases.ts @@ -27,6 +27,7 @@ export const mockStandaloneDatabaseEntity: DatabaseInstanceEntity = { provider: HostingProvider.LOCALHOST, modules: '[]', encryption: null, + tlsServername: 'server-name', }; export const mockOSSClusterDatabaseEntity: DatabaseInstanceEntity = { diff --git a/redisinsight/api/src/app.module.ts b/redisinsight/api/src/app.module.ts index 9ad75e0129..fe24271c1c 100644 --- a/redisinsight/api/src/app.module.ts +++ b/redisinsight/api/src/app.module.ts @@ -12,6 +12,7 @@ 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 { PubSubModule } from 'src/modules/pub-sub/pub-sub.module'; import { SharedModule } from './modules/shared/shared.module'; import { InstancesModule } from './modules/instances/instances.module'; import { BrowserModule } from './modules/browser/browser.module'; @@ -43,6 +44,7 @@ const PATH_CONFIG = config.get('dir_path'); PluginModule, CommandsModule, ProfilerModule, + PubSubModule, SlowLogModule, EventEmitterModule.forRoot(), ...(SERVER_CONFIG.staticContent diff --git a/redisinsight/api/src/app.routes.ts b/redisinsight/api/src/app.routes.ts index 97ccd5e98f..2c529122b7 100644 --- a/redisinsight/api/src/app.routes.ts +++ b/redisinsight/api/src/app.routes.ts @@ -6,6 +6,7 @@ import { RedisSentinelModule } from 'src/modules/redis-sentinel/redis-sentinel.m 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'; +import { PubSubModule } from 'src/modules/pub-sub/pub-sub.module'; export const routes: Routes = [ { @@ -28,6 +29,10 @@ export const routes: Routes = [ path: '/:dbInstance', module: SlowLogModule, }, + { + path: '/:dbInstance', + module: PubSubModule, + }, ], }, { diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index b635436495..f876f60043 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -3,6 +3,7 @@ export default { INVALID_DATABASE_INSTANCE_ID: 'Invalid database instance id.', COMMAND_EXECUTION_NOT_FOUND: 'Command execution was not found.', PROFILER_LOG_FILE_NOT_FOUND: 'Profiler log file was not found.', + CONSUMER_GROUP_NOT_FOUND: 'Consumer Group with such name was not found.', PLUGIN_STATE_NOT_FOUND: 'Plugin state was not found.', UNDEFINED_INSTANCE_ID: 'Undefined redis database instance id.', NO_CONNECTION_TO_REDIS_DB: 'No connection to the Redis Database.', diff --git a/redisinsight/api/src/constants/redis-error-codes.ts b/redisinsight/api/src/constants/redis-error-codes.ts index a7123dd422..caf687a9a3 100644 --- a/redisinsight/api/src/constants/redis-error-codes.ts +++ b/redisinsight/api/src/constants/redis-error-codes.ts @@ -10,6 +10,8 @@ export enum RedisErrorCodes { ConnectionReset = 'ECONNRESET', Timeout = 'ETIMEDOUT', CommandSyntaxError = 'syntax error', + BusyGroup = 'BUSYGROUP', + NoGroup = 'NOGROUP', UnknownCommand = 'unknown command', } diff --git a/redisinsight/api/src/constants/telemetry-events.ts b/redisinsight/api/src/constants/telemetry-events.ts index bbeb4c4f60..5f54af6b05 100644 --- a/redisinsight/api/src/constants/telemetry-events.ts +++ b/redisinsight/api/src/constants/telemetry-events.ts @@ -45,4 +45,9 @@ export enum TelemetryEvents { // Slowlog SlowlogSetLogSlowerThan = 'SLOWLOG_SET_LOG_SLOWER_THAN', SlowlogSetMaxLen = 'SLOWLOG_SET_MAX_LEN', + + // Pub/Sub + PubSubMessagePublished = 'PUBSUB_MESSAGE_PUBLISHED', + PubSubChannelSubscribed = 'PUBSUB_CHANNEL_SUBSCRIBED', + PubSubChannelUnsubscribed = 'PUBSUB_CHANNEL_UNSUBSCRIBED', } diff --git a/redisinsight/api/src/modules/browser/browser.module.ts b/redisinsight/api/src/modules/browser/browser.module.ts index 34a1d37850..d9e66d305e 100644 --- a/redisinsight/api/src/modules/browser/browser.module.ts +++ b/redisinsight/api/src/modules/browser/browser.module.ts @@ -4,6 +4,10 @@ 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 { ConsumerGroupController } from 'src/modules/browser/controllers/stream/consumer-group.controller'; +import { ConsumerGroupService } from 'src/modules/browser/services/stream/consumer-group.service'; +import { ConsumerController } from 'src/modules/browser/controllers/stream/consumer.controller'; +import { ConsumerService } from 'src/modules/browser/services/stream/consumer.service'; import { HashController } from './controllers/hash/hash.controller'; import { KeysController } from './controllers/keys/keys.controller'; import { KeysBusinessService } from './services/keys-business/keys-business.service'; @@ -32,6 +36,8 @@ import { BrowserToolClusterService } from './services/browser-tool-cluster/brows RejsonRlController, HashController, StreamController, + ConsumerGroupController, + ConsumerController, ], providers: [ KeysBusinessService, @@ -42,6 +48,8 @@ import { BrowserToolClusterService } from './services/browser-tool-cluster/brows RejsonRlBusinessService, HashBusinessService, StreamService, + ConsumerGroupService, + ConsumerService, BrowserToolService, BrowserToolClusterService, ], @@ -58,6 +66,9 @@ export class BrowserModule implements NestModule { RouterModule.resolvePath(SetController), RouterModule.resolvePath(ZSetController), RouterModule.resolvePath(RejsonRlController), + RouterModule.resolvePath(StreamController), + RouterModule.resolvePath(ConsumerGroupController), + RouterModule.resolvePath(ConsumerController), ); } } 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 8b8a1436c2..c506704451 100644 --- a/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts +++ b/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts @@ -80,6 +80,15 @@ export enum BrowserToolStreamCommands { XRevRange = 'xrevrange', XAdd = 'xadd', XDel = 'xdel', + XInfoGroups = 'xinfo groups', + XInfoConsumers = 'xinfo consumers', + XPending = 'xpending', + XAck = 'xack', + XClaim = 'xclaim', + XGroupCreate = 'xgroup create', + XGroupSetId = 'xgroup setid', + XGroupDestroy = 'xgroup destroy', + XGroupDelConsumer = 'xgroup delconsumer', } export enum BrowserToolTSCommands { diff --git a/redisinsight/api/src/modules/browser/controllers/stream/consumer-group.controller.ts b/redisinsight/api/src/modules/browser/controllers/stream/consumer-group.controller.ts new file mode 100644 index 0000000000..1a97b260e5 --- /dev/null +++ b/redisinsight/api/src/modules/browser/controllers/stream/consumer-group.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, Delete, + Param, Patch, + Post, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiRedisInstanceOperation } from 'src/decorators/api-redis-instance-operation.decorator'; +import { + ConsumerGroupDto, + CreateConsumerGroupsDto, + DeleteConsumerGroupsDto, + DeleteConsumerGroupsResponse, + UpdateConsumerGroupDto, +} from 'src/modules/browser/dto/stream.dto'; +import { ConsumerGroupService } from 'src/modules/browser/services/stream/consumer-group.service'; +import { KeyDto } from 'src/modules/browser/dto'; + +@ApiTags('Streams') +@Controller('streams/consumer-groups') +@UsePipes(new ValidationPipe({ transform: true })) +export class ConsumerGroupController { + constructor(private service: ConsumerGroupService) {} + + @Post('/get') + @ApiRedisInstanceOperation({ + description: 'Get consumer groups list', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Returns stream consumer groups.', + type: ConsumerGroupDto, + isArray: true, + }, + ], + }) + async getGroups( + @Param('dbInstance') instanceId: string, + @Body() dto: KeyDto, + ): Promise { + return this.service.getGroups({ instanceId }, dto); + } + + @Post('') + @ApiRedisInstanceOperation({ + description: 'Create stream consumer group', + statusCode: 201, + }) + async createGroups( + @Param('dbInstance') instanceId: string, + @Body() dto: CreateConsumerGroupsDto, + ): Promise { + return this.service.createGroups({ instanceId }, dto); + } + + @Patch('') + @ApiRedisInstanceOperation({ + description: 'Modify last delivered ID of the Consumer Group', + statusCode: 200, + }) + async updateGroup( + @Param('dbInstance') instanceId: string, + @Body() dto: UpdateConsumerGroupDto, + ): Promise { + return this.service.updateGroup({ instanceId }, dto); + } + + @Delete('') + @ApiRedisInstanceOperation({ + description: 'Delete Consumer Group', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Returns number of affected consumer groups.', + type: DeleteConsumerGroupsResponse, + }, + ], + }) + async deleteGroup( + @Param('dbInstance') instanceId: string, + @Body() dto: DeleteConsumerGroupsDto, + ): Promise { + return this.service.deleteGroup({ instanceId }, dto); + } +} diff --git a/redisinsight/api/src/modules/browser/controllers/stream/consumer.controller.ts b/redisinsight/api/src/modules/browser/controllers/stream/consumer.controller.ts new file mode 100644 index 0000000000..7f8eb12328 --- /dev/null +++ b/redisinsight/api/src/modules/browser/controllers/stream/consumer.controller.ts @@ -0,0 +1,110 @@ +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 { + AckPendingEntriesDto, AckPendingEntriesResponse, ClaimPendingEntriesResponse, ClaimPendingEntryDto, + ConsumerDto, + ConsumerGroupDto, DeleteConsumersDto, + GetConsumersDto, GetPendingEntriesDto, PendingEntryDto, +} from 'src/modules/browser/dto/stream.dto'; +import { ConsumerService } from 'src/modules/browser/services/stream/consumer.service'; + +@ApiTags('Streams') +@Controller('streams/consumer-groups/consumers') +@UsePipes(new ValidationPipe({ transform: true })) +export class ConsumerController { + constructor(private service: ConsumerService) {} + + @Post('/get') + @ApiRedisInstanceOperation({ + description: 'Get consumers list in the group', + statusCode: 200, + responses: [ + { + status: 200, + type: ConsumerGroupDto, + isArray: true, + }, + ], + }) + async getConsumers( + @Param('dbInstance') instanceId: string, + @Body() dto: GetConsumersDto, + ): Promise { + return this.service.getConsumers({ instanceId }, dto); + } + + @Delete('') + @ApiRedisInstanceOperation({ + description: 'Delete Consumer(s) from the Consumer Group', + statusCode: 200, + }) + async deleteConsumers( + @Param('dbInstance') instanceId: string, + @Body() dto: DeleteConsumersDto, + ): Promise { + return this.service.deleteConsumers({ instanceId }, dto); + } + + @Post('/pending-messages/get') + @ApiRedisInstanceOperation({ + description: 'Get pending entries list', + statusCode: 200, + responses: [ + { + status: 200, + type: PendingEntryDto, + isArray: true, + }, + ], + }) + async getPendingEntries( + @Param('dbInstance') instanceId: string, + @Body() dto: GetPendingEntriesDto, + ): Promise { + return this.service.getPendingEntries({ instanceId }, dto); + } + + @Post('/pending-messages/ack') + @ApiRedisInstanceOperation({ + description: 'Ack pending entries', + statusCode: 200, + responses: [ + { + status: 200, + type: AckPendingEntriesResponse, + }, + ], + }) + async ackPendingEntries( + @Param('dbInstance') instanceId: string, + @Body() dto: AckPendingEntriesDto, + ): Promise { + return this.service.ackPendingEntries({ instanceId }, dto); + } + + @Post('/pending-messages/claim') + @ApiRedisInstanceOperation({ + description: 'Claim pending entries', + statusCode: 200, + responses: [ + { + status: 200, + type: ClaimPendingEntriesResponse, + }, + ], + }) + async claimPendingEntries( + @Param('dbInstance') instanceId: string, + @Body() dto: ClaimPendingEntryDto, + ): Promise { + return this.service.claimPendingEntries({ instanceId }, dto); + } +} diff --git a/redisinsight/api/src/modules/browser/dto/stream.dto.ts b/redisinsight/api/src/modules/browser/dto/stream.dto.ts index def52f4187..b8496825ae 100644 --- a/redisinsight/api/src/modules/browser/dto/stream.dto.ts +++ b/redisinsight/api/src/modules/browser/dto/stream.dto.ts @@ -1,9 +1,20 @@ -import { ApiProperty, ApiPropertyOptional, IntersectionType } from '@nestjs/swagger'; +import { + ApiProperty, ApiPropertyOptional, IntersectionType, +} from '@nestjs/swagger'; import { ArrayNotEmpty, IsArray, IsDefined, - IsEnum, IsInt, IsNotEmpty, IsString, Min, ValidateNested, isString, + IsEnum, + IsInt, + IsNotEmpty, + IsString, + Min, + ValidateNested, + isString, + NotEquals, + ValidateIf, + IsBoolean, } from 'class-validator'; import { KeyDto, KeyWithExpireDto } from 'src/modules/browser/dto/keys.dto'; import { SortOrder } from 'src/constants'; @@ -183,3 +194,356 @@ export class CreateStreamDto extends IntersectionType( AddStreamEntriesDto, KeyWithExpireDto, ) {} + +export class ConsumerGroupDto { + @ApiProperty({ + type: String, + description: 'Consumer Group name', + example: 'group', + }) + name: string; + + @ApiProperty({ + type: Number, + description: 'Number of consumers', + example: 2, + }) + consumers: number = 0; + + @ApiProperty({ + type: Number, + description: 'Number of pending messages', + example: 2, + }) + pending: number = 0; + + @ApiProperty({ + type: String, + description: 'Smallest Id of the message that is pending in the group', + example: '1657892649-0', + }) + smallestPendingId: string; + + @ApiProperty({ + type: String, + description: 'Greatest Id of the message that is pending in the group', + example: '1657892680-0', + }) + greatestPendingId: string; + + @ApiProperty({ + type: String, + description: 'Id of last delivered message', + example: '1657892648-0', + }) + lastDeliveredId: string; +} + +export class CreateConsumerGroupDto { + @ApiProperty({ + type: String, + description: 'Consumer group name', + example: 'group', + }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ + type: String, + description: 'Id of last delivered message', + example: '1657892648-0', + }) + @IsNotEmpty() + @IsString() + lastDeliveredId: string; +} + +export class CreateConsumerGroupsDto extends KeyDto { + @ApiProperty({ + type: () => CreateConsumerGroupDto, + isArray: true, + description: 'List of consumer groups to create', + }) + @ValidateNested() + @IsArray() + @ArrayNotEmpty() + @Type(() => CreateConsumerGroupDto) + consumerGroups: CreateConsumerGroupDto[]; +} + +export class UpdateConsumerGroupDto extends IntersectionType( + KeyDto, + CreateConsumerGroupDto, +) {} + +export class DeleteConsumerGroupsDto extends KeyDto { + @ApiProperty({ + description: 'Consumer group names', + type: String, + isArray: true, + example: ['Group-1', 'Group-1'], + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @IsNotEmpty({ each: true }) + @IsString({ each: true }) + consumerGroups: string[]; +} + +export class DeleteConsumerGroupsResponse { + @ApiProperty({ + description: 'Number of deleted consumer groups', + type: Number, + }) + affected: number; +} + +export class ConsumerDto { + @ApiProperty({ + type: String, + description: 'The consumer\'s name', + example: 'consumer-2', + }) + name: string; + + @ApiProperty({ + type: Number, + description: 'The number of pending messages for the client, ' + + 'which are messages that were delivered but are yet to be acknowledged', + example: 2, + }) + pending: number = 0; + + @ApiProperty({ + type: Number, + description: 'The number of milliseconds that have passed since the consumer last interacted with the server', + example: 22442, + }) + idle: number = 0; +} + +export class GetConsumersDto extends KeyDto { + @ApiProperty({ + type: String, + description: 'Consumer group name', + example: 'group-1', + }) + @IsNotEmpty() + @IsString() + groupName: string; +} + +export class DeleteConsumersDto extends GetConsumersDto { + @ApiProperty({ + description: 'Names of consumers to delete', + type: String, + isArray: true, + example: ['consumer-1', 'consumer-2'], + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + consumerNames: string[]; +} + +export class PendingEntryDto { + @ApiProperty({ + type: String, + description: 'Entry ID', + example: '*', + }) + id: string; + + @ApiProperty({ + type: String, + description: 'Consumer name', + example: 'consumer-1', + }) + consumerName: string; + + @ApiProperty({ + type: Number, + description: 'The number of milliseconds that elapsed since the last time ' + + 'this message was delivered to this consumer', + example: 22442, + }) + idle: number = 0; + + @ApiProperty({ + type: Number, + description: 'The number of times this message was delivered', + example: 2, + }) + delivered: number = 0; +} + +export class GetPendingEntriesDto extends IntersectionType( + KeyDto, + GetConsumersDto, +) { + @ApiProperty({ + type: String, + description: 'Consumer name', + example: 'consumer-1', + }) + @IsNotEmpty() + @IsString() + consumerName: string; + + @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 pending messages to return.', + type: Number, + minimum: 1, + default: 500, + }) + @IsInt() + @Min(1) + count?: number = 500; +} + +export class AckPendingEntriesDto extends GetConsumersDto { + @ApiProperty({ + description: 'Entries IDs', + type: String, + isArray: true, + example: ['1650985323741-0', '1650985323770-0'], + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + entries: string[]; +} + +export class AckPendingEntriesResponse { + @ApiProperty({ + description: 'Number of affected entries', + type: Number, + }) + affected: number; +} + +export class ClaimPendingEntryDto extends KeyDto { + @ApiProperty({ + type: String, + description: 'Consumer group name', + example: 'group-1', + }) + @IsNotEmpty() + @IsString() + groupName: string; + + @ApiProperty({ + type: String, + description: 'Consumer name', + example: 'consumer-1', + }) + @IsNotEmpty() + @IsString() + consumerName: string; + + @ApiProperty({ + description: 'Claim only if its idle time is greater the minimum idle time ', + type: Number, + minimum: 0, + default: 0, + }) + @IsInt() + @Min(0) + minIdleTime: number = 0; + + @ApiProperty({ + description: 'Entries IDs', + type: String, + isArray: true, + example: ['1650985323741-0', '1650985323770-0'], + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + entries: string[]; + + @ApiPropertyOptional({ + description: 'Set the idle time (last time it was delivered) of the message', + type: Number, + minimum: 0, + }) + @NotEquals(null) + @ValidateIf((object, value) => value !== undefined) + @IsInt() + @Min(0) + idle?: number; + + @ApiPropertyOptional({ + description: 'This is the same as IDLE but instead of a relative amount of milliseconds, ' + + 'it sets the idle time to a specific Unix time (in milliseconds)', + type: Number, + }) + @NotEquals(null) + @ValidateIf((object, value) => value !== undefined) + @IsInt() + time?: number; + + @ApiPropertyOptional({ + description: 'Set the retry counter to the specified value. ' + + 'This counter is incremented every time a message is delivered again. ' + + 'Normally XCLAIM does not alter this counter, which is just served to clients when the XPENDING command ' + + 'is called: this way clients can detect anomalies, like messages that are never processed ' + + 'for some reason after a big number of delivery attempts', + type: Number, + minimum: 0, + }) + @NotEquals(null) + @ValidateIf((object, value) => value !== undefined) + @IsInt() + @Min(0) + retryCount?: number; + + @ApiPropertyOptional({ + description: 'Creates the pending message entry in the PEL even if certain specified IDs are not already ' + + 'in the PEL assigned to a different client', + type: Boolean, + }) + @NotEquals(null) + @ValidateIf((object, value) => value !== undefined) + @IsBoolean() + force?: boolean; +} + +export class ClaimPendingEntriesResponse { + @ApiProperty({ + description: 'Entries IDs were affected by claim command', + type: String, + isArray: true, + example: ['1650985323741-0', '1650985323770-0'], + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @Type(() => String) + affected: string[]; +} diff --git a/redisinsight/api/src/modules/browser/services/stream/consumer-group.service.spec.ts b/redisinsight/api/src/modules/browser/services/stream/consumer-group.service.spec.ts new file mode 100644 index 0000000000..96a4061623 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/stream/consumer-group.service.spec.ts @@ -0,0 +1,432 @@ +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 { AddStreamEntriesDto, 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 } from 'src/constants'; +import { ConsumerGroupService } from 'src/modules/browser/services/stream/consumer-group.service'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; +const mockKeyDto = { + keyName: 'keyName', +}; + +const mockStreamEntry: StreamEntryDto = { + id: '*', + fields: { + field1: 'value1', + }, +}; +const mockAddStreamEntriesDto: AddStreamEntriesDto = { + keyName: 'testList', + entries: [mockStreamEntry], +}; + +const mockConsumerGroup = { + name: 'consumer-1', + consumers: 0, + pending: 0, + lastDeliveredId: '1651130346487-0', + smallestPendingId: '1651130346480-0', + greatestPendingId: '1651130346487-0', +}; + +const mockGroupToCreate = { + name: mockConsumerGroup.name, + lastDeliveredId: mockConsumerGroup.lastDeliveredId, +}; + +const mockConsumerGroupsReply = [ + 'name', mockConsumerGroup.name, + 'consumers', mockConsumerGroup.consumers, + 'pending', mockConsumerGroup.pending, + 'last-delivered-id', mockConsumerGroup.lastDeliveredId, +]; + +describe('ConsumerGroupService', () => { + let service: ConsumerGroupService; + let browserTool: MockType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ConsumerGroupService, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + service = module.get(ConsumerGroupService); + browserTool = module.get(BrowserToolService); + + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValue(true); + }); + + describe('getGroups', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XInfoGroups, expect.anything()) + .mockResolvedValue([mockConsumerGroupsReply, mockConsumerGroupsReply]); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XPending, expect.anything()) + .mockResolvedValue(['s', mockConsumerGroup.smallestPendingId, mockConsumerGroup.greatestPendingId]); + }); + it('should get consumer groups with info', async () => { + const groups = await service.getGroups(mockClientOptions, mockKeyDto); + expect(groups).toEqual([mockConsumerGroup, mockConsumerGroup]); + }); + it('should throw error when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValueOnce(false); + + try { + await service.getGroups(mockClientOptions, mockKeyDto); + 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, expect.anything()) + .mockRejectedValueOnce(new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID)); + + try { + await service.getGroups(mockClientOptions, mockKeyDto); + 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.XPending, expect.anything()) + .mockRejectedValueOnce(new Error(RedisErrorCodes.WrongType)); + + try { + await service.getGroups(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.XPending, expect.anything()) + .mockRejectedValueOnce(new Error('oO')); + + try { + await service.getGroups(mockClientOptions, { + ...mockAddStreamEntriesDto, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('oO'); + } + }); + }); + describe('createGroups', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValue(true); + browserTool.execMulti.mockResolvedValue([null, [[null, '123-1']]]); + }); + it('add groups', async () => { + await expect( + service.createGroups(mockClientOptions, { + ...mockKeyDto, + consumerGroups: [mockGroupToCreate, mockGroupToCreate], + }), + ).resolves.not.toThrow(); + expect(browserTool.execMulti).toHaveBeenCalledWith(mockClientOptions, [ + [ + BrowserToolStreamCommands.XGroupCreate, mockKeyDto.keyName, + mockConsumerGroup.name, mockConsumerGroup.lastDeliveredId, + ], + [ + BrowserToolStreamCommands.XGroupCreate, mockKeyDto.keyName, + mockConsumerGroup.name, mockConsumerGroup.lastDeliveredId, + ], + ]); + }); + it('should throw Not Found when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValueOnce(false); + + try { + await service.createGroups(mockClientOptions, { + ...mockKeyDto, + consumerGroups: [mockGroupToCreate, mockGroupToCreate], + }); + 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, expect.anything()) + .mockRejectedValueOnce(new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID)); + + try { + await service.createGroups(mockClientOptions, { + ...mockKeyDto, + consumerGroups: [mockGroupToCreate, mockGroupToCreate], + }); + 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.createGroups(mockClientOptions, { + ...mockKeyDto, + consumerGroups: [mockGroupToCreate, mockGroupToCreate], + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual(RedisErrorCodes.WrongType); + } + }); + it('should throw Conflict when trying to create existing group', async () => { + browserTool.execMulti.mockResolvedValue([ + new Error('BUSYGROUP such group already there!'), + [[null, '123-1']], + ]); + + try { + await service.createGroups(mockClientOptions, { + ...mockKeyDto, + consumerGroups: [mockGroupToCreate, mockGroupToCreate], + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ConflictException); + expect(e.message).toEqual('BUSYGROUP such group already there!'); + } + }); + it('should throw Internal Server error', async () => { + browserTool.execMulti.mockResolvedValue([ + new Error('oO'), + [[null, '123-1']], + ]); + + try { + await service.createGroups(mockClientOptions, { + ...mockKeyDto, + consumerGroups: [mockGroupToCreate, mockGroupToCreate], + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('oO'); + } + }); + }); + describe('updateGroup', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XGroupSetId, expect.anything()) + .mockResolvedValue('OK'); + }); + it('update group', async () => { + await expect( + service.updateGroup(mockClientOptions, { + ...mockKeyDto, + ...mockGroupToCreate, + }), + ).resolves.not.toThrow(); + expect(browserTool.execCommand).toHaveBeenCalledWith( + mockClientOptions, + BrowserToolStreamCommands.XGroupSetId, + [mockKeyDto.keyName, mockGroupToCreate.name, mockGroupToCreate.lastDeliveredId], + ); + }); + it('should throw Not Found when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValueOnce(false); + + try { + await service.updateGroup(mockClientOptions, { + ...mockKeyDto, + ...mockGroupToCreate, + }); + 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, expect.anything()) + .mockRejectedValueOnce(new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID)); + + try { + await service.updateGroup(mockClientOptions, { + ...mockKeyDto, + ...mockGroupToCreate, + }); + 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.execCommand.mockRejectedValueOnce(new Error(RedisErrorCodes.WrongType)); + + try { + await service.updateGroup(mockClientOptions, { + ...mockKeyDto, + ...mockGroupToCreate, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual(RedisErrorCodes.WrongType); + } + }); + it('should throw NotFound when trying to modify not-existing group', async () => { + browserTool.execCommand.mockRejectedValueOnce(new Error('NOGROUP no such group')); + + try { + await service.updateGroup(mockClientOptions, { + ...mockKeyDto, + ...mockGroupToCreate, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.CONSUMER_GROUP_NOT_FOUND); + } + }); + it('should throw Internal Server error', async () => { + browserTool.execCommand.mockRejectedValueOnce(new Error('oO')); + + try { + await service.updateGroup(mockClientOptions, { + ...mockKeyDto, + ...mockGroupToCreate, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('oO'); + } + }); + }); + describe('deleteGroups', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValue(true); + browserTool.execMulti.mockResolvedValue([null, [[null, '123-1']]]); + }); + it('add groups', async () => { + await expect( + service.deleteGroup(mockClientOptions, { + ...mockKeyDto, + consumerGroups: [mockGroupToCreate.name, mockGroupToCreate.name], + }), + ).resolves.not.toThrow(); + expect(browserTool.execMulti).toHaveBeenCalledWith(mockClientOptions, [ + [BrowserToolStreamCommands.XGroupDestroy, mockKeyDto.keyName, mockConsumerGroup.name], + [BrowserToolStreamCommands.XGroupDestroy, mockKeyDto.keyName, mockConsumerGroup.name], + ]); + }); + it('should throw Not Found when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValueOnce(false); + + try { + await service.deleteGroup(mockClientOptions, { + ...mockKeyDto, + consumerGroups: [mockGroupToCreate.name, mockGroupToCreate.name], + }); + 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, expect.anything()) + .mockRejectedValueOnce(new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID)); + + try { + await service.deleteGroup(mockClientOptions, { + ...mockKeyDto, + consumerGroups: [mockGroupToCreate.name, mockGroupToCreate.name], + }); + 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.deleteGroup(mockClientOptions, { + ...mockKeyDto, + consumerGroups: [mockGroupToCreate.name, mockGroupToCreate.name], + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual(RedisErrorCodes.WrongType); + } + }); + it('should throw Internal Server error', async () => { + browserTool.execMulti.mockResolvedValue([ + new Error('oO'), + [[null, '123-1']], + ]); + + try { + await service.deleteGroup(mockClientOptions, { + ...mockKeyDto, + consumerGroups: [mockGroupToCreate.name, mockGroupToCreate.name], + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('oO'); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/stream/consumer-group.service.ts b/redisinsight/api/src/modules/browser/services/stream/consumer-group.service.ts new file mode 100644 index 0000000000..61619249d3 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/stream/consumer-group.service.ts @@ -0,0 +1,315 @@ +import { + BadRequestException, ConflictException, Injectable, Logger, NotFoundException, +} from '@nestjs/common'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { RedisErrorCodes } from 'src/constants'; +import { catchAclError, catchTransactionError, convertStringsArrayToObject } from 'src/utils'; +import { + BrowserToolCommands, + BrowserToolKeysCommands, BrowserToolStreamCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { KeyDto } from 'src/modules/browser/dto'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { + ConsumerGroupDto, + CreateConsumerGroupsDto, + DeleteConsumerGroupsDto, DeleteConsumerGroupsResponse, + UpdateConsumerGroupDto, +} from 'src/modules/browser/dto/stream.dto'; + +@Injectable() +export class ConsumerGroupService { + private logger = new Logger('ConsumerGroupService'); + + constructor(private browserTool: BrowserToolService) {} + + /** + * Get consumer groups list for particular stream + * In addition fetch pending messages info for each group + * !May be slow on huge streams as 'XPENDING' command tagged with as @slow + * @param clientOptions + * @param dto + */ + async getGroups( + clientOptions: IFindRedisClientInstanceByOptions, + dto: KeyDto, + ): Promise { + try { + this.logger.log('Getting consumer groups list.'); + + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [dto.keyName], + ); + + if (!exists) { + return Promise.reject(new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST)); + } + + const groups = ConsumerGroupService.formatReplyToDto(await this.browserTool.execCommand( + clientOptions, + BrowserToolStreamCommands.XInfoGroups, + [dto.keyName], + )); + + return await Promise.all(groups.map((group) => this.getGroupInfo( + clientOptions, + dto, + group, + ))); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + + throw catchAclError(error); + } + } + + /** + * Get consumer group pending info using 'XPENDING' command + * @param clientOptions + * @param dto + * @param group + */ + async getGroupInfo( + clientOptions: IFindRedisClientInstanceByOptions, + dto: KeyDto, + group: ConsumerGroupDto, + ): Promise { + const info = await this.browserTool.execCommand( + clientOptions, + BrowserToolStreamCommands.XPending, + [dto.keyName, group.name], + ); + + return { + ...group, + smallestPendingId: info?.[1] || null, + greatestPendingId: info?.[2] || null, + }; + } + + /** + * Create consumer group(s) + * @param clientOptions + * @param dto + */ + async createGroups( + clientOptions: IFindRedisClientInstanceByOptions, + dto: CreateConsumerGroupsDto, + ): Promise { + try { + this.logger.log('Creating consumer groups.'); + const { keyName, consumerGroups } = dto; + + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + + if (!exists) { + return Promise.reject(new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST)); + } + + const toolCommands: Array<[ + toolCommand: BrowserToolCommands, + ...args: Array, + ]> = consumerGroups.map((group) => ( + [ + BrowserToolStreamCommands.XGroupCreate, + keyName, + group.name, + group.lastDeliveredId, + ] + )); + + const [ + transactionError, + transactionResults, + ] = await this.browserTool.execMulti(clientOptions, toolCommands); + catchTransactionError(transactionError, transactionResults); + + this.logger.log('Stream consumer group(s) created.'); + + return undefined; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + + if (error?.message.includes(RedisErrorCodes.BusyGroup)) { + throw new ConflictException(error.message); + } + + throw catchAclError(error); + } + } + + /** + * Updates last delivered id for Consumer Group + * @param clientOptions + * @param dto + */ + async updateGroup( + clientOptions: IFindRedisClientInstanceByOptions, + dto: UpdateConsumerGroupDto, + ): Promise { + try { + this.logger.log('Updating consumer group.'); + + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [dto.keyName], + ); + + if (!exists) { + return Promise.reject(new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST)); + } + + await this.browserTool.execCommand( + clientOptions, + BrowserToolStreamCommands.XGroupSetId, + [dto.keyName, dto.name, dto.lastDeliveredId], + ); + + this.logger.log('Consumer group was updated.'); + + return undefined; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + + if (error?.message.includes(RedisErrorCodes.NoGroup)) { + throw new NotFoundException(ERROR_MESSAGES.CONSUMER_GROUP_NOT_FOUND); + } + + throw catchAclError(error); + } + } + + /** + * Delete consumer groups in batch + * @param clientOptions + * @param dto + */ + async deleteGroup( + clientOptions: IFindRedisClientInstanceByOptions, + dto: DeleteConsumerGroupsDto, + ): Promise { + try { + this.logger.log('Deleting consumer group.'); + + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [dto.keyName], + ); + + if (!exists) { + return Promise.reject(new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST)); + } + + const toolCommands: Array<[ + toolCommand: BrowserToolCommands, + ...args: Array, + ]> = dto.consumerGroups.map((group) => ( + [ + BrowserToolStreamCommands.XGroupDestroy, + dto.keyName, + group, + ] + )); + + const [ + transactionError, + transactionResults, + ] = await this.browserTool.execMulti(clientOptions, toolCommands); + catchTransactionError(transactionError, transactionResults); + + this.logger.log('Consumer group(s) successfully deleted.'); + + return { + affected: toolCommands.length, + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + + throw catchAclError(error); + } + } + + /** + * Converts RESP response from Redis + * [ + * ['name', 'g1', 'consumers', 0, 'pending', 0, 'last-delivered-id', '1653034260278-0'], + * ['name', 'g2', 'consumers', 0, 'pending', 0, 'last-delivered-id', '1653034260278-0'], + * ... + * ] + * + * to DTO + * + * [ + * { + * name: 'g1', + * consumers: 0, + * pending: 0, + * lastDeliveredId: '1653034260278-0' + * }, + * { + * name: 'g2', + * consumers: 0, + * pending: 0, + * lastDeliveredId: '1653034260278-0' + * }, + * ... + * ] + * @param reply + */ + static formatReplyToDto(reply: Array>): ConsumerGroupDto[] { + return reply.map(ConsumerGroupService.formatArrayToDto); + } + + /** + * Format single reply entry to DTO + * @param entry + */ + static formatArrayToDto(entry: Array): ConsumerGroupDto { + if (!entry?.length) { + return null; + } + const entryObj = convertStringsArrayToObject(entry as string[]); + + return { + name: entryObj['name'], + consumers: entryObj['consumers'], + pending: entryObj['pending'], + lastDeliveredId: entryObj['last-delivered-id'], + smallestPendingId: null, + greatestPendingId: null, + }; + } +} diff --git a/redisinsight/api/src/modules/browser/services/stream/consumer.service.spec.ts b/redisinsight/api/src/modules/browser/services/stream/consumer.service.spec.ts new file mode 100644 index 0000000000..c412f34884 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/stream/consumer.service.spec.ts @@ -0,0 +1,594 @@ +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 { + BadRequestException, InternalServerErrorException, NotFoundException, +} from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { RedisErrorCodes } from 'src/constants'; +import { ConsumerService } from 'src/modules/browser/services/stream/consumer.service'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; +const mockKeyDto = { + keyName: 'keyName', +}; + +const mockConsumerGroup = { + name: 'group-1', + consumers: 0, + pending: 0, + lastDeliveredId: '1651130346487-0', + smallestPendingId: '1651130346480-0', + greatestPendingId: '1651130346487-0', +}; + +const mockConsumer = { + name: 'consumer-1', + pending: 0, + idle: 10, +}; + +const mockConsumerReply = [ + 'name', mockConsumer.name, + 'pending', mockConsumer.pending, + 'idle', mockConsumer.idle, +]; + +const mockGetPendingMessagesDto = { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + start: '-', + end: '+', + count: 10, + consumerName: mockConsumer.name, +}; + +const mockPendingMessage = { + id: '1651130346487-0', + consumerName: mockConsumer.name, + idle: mockConsumer.idle, + delivered: 1, +}; + +const mockPendingMessageReply = Object.values(mockPendingMessage); + +const mockAckPendingMessagesDto = { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + entries: [mockPendingMessage.id, mockPendingMessage.id], +}; + +const mockClaimPendingEntriesDto = { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + consumerName: mockConsumer.name, + entries: [mockPendingMessage.id, mockPendingMessage.id], + minIdleTime: 0, +}; +const mockAdditionalClaimPendingEntriesDto = { + time: 0, + retryCount: 1, + force: true, +}; + +const mockClaimPendingEntriesReply = [ + mockPendingMessage.id, mockPendingMessage.id, +]; + +describe('ConsumerService', () => { + let service: ConsumerService; + let browserTool: MockType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ConsumerService, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + service = module.get(ConsumerService); + browserTool = module.get(BrowserToolService); + + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValue(true); + }); + + describe('getGroups', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XInfoConsumers, expect.anything()) + .mockResolvedValue([mockConsumerReply, mockConsumerReply]); + }); + it('should get consumers list', async () => { + const consumers = await service.getConsumers(mockClientOptions, { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + }); + expect(consumers).toEqual([mockConsumer, mockConsumer]); + }); + it('should throw error when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValueOnce(false); + + try { + await service.getConsumers(mockClientOptions, { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + }); + 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, expect.anything()) + .mockRejectedValueOnce(new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID)); + + try { + await service.getConsumers(mockClientOptions, { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID); + } + }); + it('should throw Not Found error when no group', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XInfoConsumers, expect.anything()) + .mockRejectedValueOnce(new Error('NOGROUP no such group')); + + try { + await service.getConsumers(mockClientOptions, { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.CONSUMER_GROUP_NOT_FOUND); + } + }); + it('should throw Wrong Type error', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XInfoConsumers, expect.anything()) + .mockRejectedValueOnce(new Error(RedisErrorCodes.WrongType)); + + try { + await service.getConsumers(mockClientOptions, { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + }); + 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.XInfoConsumers, expect.anything()) + .mockRejectedValueOnce(new Error('oO')); + + try { + await service.getConsumers(mockClientOptions, { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('oO'); + } + }); + }); + describe('deleteConsumers', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValue(true); + browserTool.execMulti.mockResolvedValue([null, [[null, '123-1']]]); + }); + it('delete consumers', async () => { + await expect( + service.deleteConsumers(mockClientOptions, { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + consumerNames: [mockConsumer.name, mockConsumer.name], + }), + ).resolves.not.toThrow(); + expect(browserTool.execMulti).toHaveBeenCalledWith(mockClientOptions, [ + [ + BrowserToolStreamCommands.XGroupDelConsumer, mockKeyDto.keyName, + mockConsumerGroup.name, mockConsumer.name, + ], + [ + BrowserToolStreamCommands.XGroupDelConsumer, mockKeyDto.keyName, + mockConsumerGroup.name, mockConsumer.name, + ], + ]); + }); + it('should throw Not Found when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValueOnce(false); + + try { + await service.deleteConsumers(mockClientOptions, { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + consumerNames: [mockConsumer.name, mockConsumer.name], + }); + 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, expect.anything()) + .mockRejectedValueOnce(new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID)); + + try { + await service.deleteConsumers(mockClientOptions, { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + consumerNames: [mockConsumer.name, mockConsumer.name], + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID); + } + }); + it('should throw Not Found error when group does not exists', async () => { + browserTool.execMulti.mockResolvedValue([new Error(RedisErrorCodes.NoGroup), [[null, '123-1']]]); + + try { + await service.deleteConsumers(mockClientOptions, { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + consumerNames: [mockConsumer.name, mockConsumer.name], + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.CONSUMER_GROUP_NOT_FOUND); + } + }); + it('should throw Wrong Type error', async () => { + browserTool.execMulti.mockResolvedValue([new Error(RedisErrorCodes.WrongType), [[null, '123-1']]]); + + try { + await service.deleteConsumers(mockClientOptions, { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + consumerNames: [mockConsumer.name, mockConsumer.name], + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual(RedisErrorCodes.WrongType); + } + }); + it('should throw Internal Server error', async () => { + browserTool.execMulti.mockResolvedValue([ + new Error('oO'), + [[null, '123-1']], + ]); + + try { + await service.deleteConsumers(mockClientOptions, { + ...mockKeyDto, + groupName: mockConsumerGroup.name, + consumerNames: [mockConsumer.name, mockConsumer.name], + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('oO'); + } + }); + }); + describe('getPendingEntries', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XPending, expect.anything()) + .mockResolvedValue([mockPendingMessageReply, mockPendingMessageReply]); + }); + it('should get consumers list', async () => { + const consumers = await service.getPendingEntries(mockClientOptions, mockGetPendingMessagesDto); + expect(consumers).toEqual([mockPendingMessage, mockPendingMessage]); + expect(browserTool.execCommand).toHaveBeenCalledWith( + mockClientOptions, + BrowserToolStreamCommands.XPending, + Object.values(mockGetPendingMessagesDto), + ); + }); + it('should throw error when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValueOnce(false); + + try { + await service.getPendingEntries(mockClientOptions, mockGetPendingMessagesDto); + 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, expect.anything()) + .mockRejectedValueOnce(new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID)); + + try { + await service.getPendingEntries(mockClientOptions, mockGetPendingMessagesDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID); + } + }); + it('should throw Not Found error when no group', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XPending, expect.anything()) + .mockRejectedValueOnce(new Error('NOGROUP no such group')); + + try { + await service.getPendingEntries(mockClientOptions, mockGetPendingMessagesDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.CONSUMER_GROUP_NOT_FOUND); + } + }); + it('should throw Wrong Type error', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XPending, expect.anything()) + .mockRejectedValueOnce(new Error(RedisErrorCodes.WrongType)); + + try { + await service.getPendingEntries(mockClientOptions, mockGetPendingMessagesDto); + 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.XPending, expect.anything()) + .mockRejectedValueOnce(new Error('oO')); + + try { + await service.getPendingEntries(mockClientOptions, mockGetPendingMessagesDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('oO'); + } + }); + }); + describe('ackPendingEntries', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValue(true); + browserTool.execCommand.mockResolvedValue(2); + }); + it('ack pending entries', async () => { + const result = await service.ackPendingEntries(mockClientOptions, mockAckPendingMessagesDto); + expect(result).toEqual({ affected: 2 }); + + expect(browserTool.execCommand).toHaveBeenCalledWith(mockClientOptions, + BrowserToolStreamCommands.XAck, + [ + mockAckPendingMessagesDto.keyName, + mockAckPendingMessagesDto.groupName, + ...mockAckPendingMessagesDto.entries, + ]); + }); + it('should throw Not Found when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValueOnce(false); + + try { + await service.ackPendingEntries(mockClientOptions, mockAckPendingMessagesDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.KEY_NOT_EXIST); + } + }); + it('should proxy Not Found error', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XAck, expect.anything()) + .mockRejectedValueOnce(new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID)); + + try { + await service.ackPendingEntries(mockClientOptions, mockAckPendingMessagesDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID); + } + }); + it('should throw Bad Request when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XAck, expect.anything()) + .mockRejectedValueOnce(new Error(RedisErrorCodes.WrongType)); + + try { + await service.ackPendingEntries(mockClientOptions, mockAckPendingMessagesDto); + 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.XAck, expect.anything()) + .mockRejectedValueOnce(new Error('oO')); + + try { + await service.ackPendingEntries(mockClientOptions, mockAckPendingMessagesDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('oO'); + } + }); + }); + describe('ackPendingEntries', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XClaim, expect.anything()) + .mockResolvedValue(mockClaimPendingEntriesReply); + }); + it('claim pending entries', async () => { + const result = await service.claimPendingEntries(mockClientOptions, mockClaimPendingEntriesDto); + expect(result).toEqual({ affected: mockClaimPendingEntriesReply }); + + expect(browserTool.execCommand).toHaveBeenCalledWith(mockClientOptions, + BrowserToolStreamCommands.XClaim, + [ + mockClaimPendingEntriesDto.keyName, + mockClaimPendingEntriesDto.groupName, + mockClaimPendingEntriesDto.consumerName, + mockClaimPendingEntriesDto.minIdleTime, + ...mockClaimPendingEntriesDto.entries, + 'justid', + ]); + }); + it('claim pending entries with additional args', async () => { + const result = await service.claimPendingEntries(mockClientOptions, { + ...mockClaimPendingEntriesDto, + ...mockAdditionalClaimPendingEntriesDto, + }); + expect(result).toEqual({ affected: mockClaimPendingEntriesReply }); + + expect(browserTool.execCommand).toHaveBeenCalledWith(mockClientOptions, + BrowserToolStreamCommands.XClaim, + [ + mockClaimPendingEntriesDto.keyName, + mockClaimPendingEntriesDto.groupName, + mockClaimPendingEntriesDto.consumerName, + mockClaimPendingEntriesDto.minIdleTime, + ...mockClaimPendingEntriesDto.entries, + 'time', mockAdditionalClaimPendingEntriesDto.time, + 'retrycount', mockAdditionalClaimPendingEntriesDto.retryCount, + 'force', + 'justid', + ]); + }); + it('claim pending entries with additional args and "idle" instead of "time"', async () => { + const result = await service.claimPendingEntries(mockClientOptions, { + ...mockClaimPendingEntriesDto, + ...mockAdditionalClaimPendingEntriesDto, + idle: 0, + }); + expect(result).toEqual({ affected: mockClaimPendingEntriesReply }); + + expect(browserTool.execCommand).toHaveBeenCalledWith(mockClientOptions, + BrowserToolStreamCommands.XClaim, + [ + mockClaimPendingEntriesDto.keyName, + mockClaimPendingEntriesDto.groupName, + mockClaimPendingEntriesDto.consumerName, + mockClaimPendingEntriesDto.minIdleTime, + ...mockClaimPendingEntriesDto.entries, + 'idle', 0, + 'retrycount', mockAdditionalClaimPendingEntriesDto.retryCount, + 'force', + 'justid', + ]); + }); + it('should throw Not Found when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, expect.anything()) + .mockResolvedValueOnce(false); + + try { + await service.claimPendingEntries(mockClientOptions, mockClaimPendingEntriesDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.KEY_NOT_EXIST); + } + }); + it('should proxy Not Found error', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XClaim, expect.anything()) + .mockRejectedValueOnce(new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID)); + + try { + await service.claimPendingEntries(mockClientOptions, mockClaimPendingEntriesDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID); + } + }); + it('should throw Bad Request when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XClaim, expect.anything()) + .mockRejectedValueOnce(new Error(RedisErrorCodes.WrongType)); + + try { + await service.claimPendingEntries(mockClientOptions, mockClaimPendingEntriesDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual(RedisErrorCodes.WrongType); + } + }); + it('should throw Not Found when key does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XClaim, expect.anything()) + .mockRejectedValueOnce(new Error(RedisErrorCodes.NoGroup)); + + try { + await service.claimPendingEntries(mockClientOptions, mockClaimPendingEntriesDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.CONSUMER_GROUP_NOT_FOUND); + } + }); + it('should throw Internal Server error', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStreamCommands.XClaim, expect.anything()) + .mockRejectedValueOnce(new Error('oO')); + + try { + await service.claimPendingEntries(mockClientOptions, mockClaimPendingEntriesDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('oO'); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/stream/consumer.service.ts b/redisinsight/api/src/modules/browser/services/stream/consumer.service.ts new file mode 100644 index 0000000000..b31cae1230 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/stream/consumer.service.ts @@ -0,0 +1,373 @@ +import { + BadRequestException, Injectable, Logger, NotFoundException, +} from '@nestjs/common'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { RedisErrorCodes } from 'src/constants'; +import { catchAclError, catchTransactionError, convertStringsArrayToObject } from 'src/utils'; +import { + BrowserToolCommands, + BrowserToolKeysCommands, BrowserToolStreamCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { + AckPendingEntriesDto, AckPendingEntriesResponse, ClaimPendingEntriesResponse, ClaimPendingEntryDto, + ConsumerDto, DeleteConsumersDto, + GetConsumersDto, GetPendingEntriesDto, PendingEntryDto, +} from 'src/modules/browser/dto/stream.dto'; + +@Injectable() +export class ConsumerService { + private logger = new Logger('ConsumerService'); + + constructor(private browserTool: BrowserToolService) {} + + /** + * Get consumers list inside particular group + * @param clientOptions + * @param dto + */ + async getConsumers( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetConsumersDto, + ): Promise { + try { + this.logger.log('Getting consumers list.'); + + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [dto.keyName], + ); + + if (!exists) { + return Promise.reject(new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST)); + } + + return ConsumerService.formatReplyToDto(await this.browserTool.execCommand( + clientOptions, + BrowserToolStreamCommands.XInfoConsumers, + [dto.keyName, dto.groupName], + )); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + if (error?.message.includes(RedisErrorCodes.NoGroup)) { + throw new NotFoundException(ERROR_MESSAGES.CONSUMER_GROUP_NOT_FOUND); + } + + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + + throw catchAclError(error); + } + } + + /** + * Get consumers list inside particular group + * @param clientOptions + * @param dto + */ + async deleteConsumers( + clientOptions: IFindRedisClientInstanceByOptions, + dto: DeleteConsumersDto, + ): Promise { + try { + this.logger.log('Deleting consumers from the group.'); + + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [dto.keyName], + ); + + if (!exists) { + return Promise.reject(new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST)); + } + + const toolCommands: Array<[ + toolCommand: BrowserToolCommands, + ...args: Array, + ]> = dto.consumerNames.map((consumerName) => ( + [ + BrowserToolStreamCommands.XGroupDelConsumer, + dto.keyName, + dto.groupName, + consumerName, + ] + )); + + const [ + transactionError, + transactionResults, + ] = await this.browserTool.execMulti(clientOptions, toolCommands); + catchTransactionError(transactionError, transactionResults); + + return undefined; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + if (error?.message.includes(RedisErrorCodes.NoGroup)) { + throw new NotFoundException(ERROR_MESSAGES.CONSUMER_GROUP_NOT_FOUND); + } + + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + + throw catchAclError(error); + } + } + + /** + * Get list of pending entries info for particular consumer + * @param clientOptions + * @param dto + */ + async getPendingEntries( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetPendingEntriesDto, + ): Promise { + try { + this.logger.log('Getting pending entries list.'); + + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [dto.keyName], + ); + + if (!exists) { + return Promise.reject(new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST)); + } + + return ConsumerService.formatReplyToPendingEntriesDto(await this.browserTool.execCommand( + clientOptions, + BrowserToolStreamCommands.XPending, + [dto.keyName, dto.groupName, dto.start, dto.end, dto.count, dto.consumerName], + )); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + if (error?.message.includes(RedisErrorCodes.NoGroup)) { + throw new NotFoundException(ERROR_MESSAGES.CONSUMER_GROUP_NOT_FOUND); + } + + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + + throw catchAclError(error); + } + } + + /** + * Acknowledge pending entries + * @param clientOptions + * @param dto + */ + async ackPendingEntries( + clientOptions: IFindRedisClientInstanceByOptions, + dto: AckPendingEntriesDto, + ): Promise { + try { + this.logger.log('Acknowledging pending entries.'); + + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [dto.keyName], + ); + + if (!exists) { + return Promise.reject(new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST)); + } + + const affected = await this.browserTool.execCommand( + clientOptions, + BrowserToolStreamCommands.XAck, + [dto.keyName, dto.groupName, ...dto.entries], + ); + + this.logger.log('Successfully acknowledged pending entries.'); + + return { + affected, + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + + throw catchAclError(error); + } + } + + /** + * Claim pending entries with additional parameters + * @param clientOptions + * @param dto + */ + async claimPendingEntries( + clientOptions: IFindRedisClientInstanceByOptions, + dto: ClaimPendingEntryDto, + ): Promise { + try { + this.logger.log('Claiming pending entries.'); + + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [dto.keyName], + ); + + if (!exists) { + return Promise.reject(new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST)); + } + + const args = [dto.keyName, dto.groupName, dto.consumerName, dto.minIdleTime, ...dto.entries]; + + if (dto.idle !== undefined) { + args.push('idle', dto.idle); + } else if (dto.time !== undefined) { + args.push('time', dto.time); + } + + if (dto.retryCount !== undefined) { + args.push('retrycount', dto.retryCount); + } + + if (dto.force) { + args.push('force'); + } + + // Return just an array of IDs of messages successfully claimed, without returning the actual message. + args.push('justid'); + + const affected = await this.browserTool.execCommand( + clientOptions, + BrowserToolStreamCommands.XClaim, + args, + ); + + this.logger.log('Successfully claimed pending entries.'); + + return { + affected, + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + if (error?.message.includes(RedisErrorCodes.NoGroup)) { + throw new NotFoundException(ERROR_MESSAGES.CONSUMER_GROUP_NOT_FOUND); + } + + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + + throw catchAclError(error); + } + } + + /** + * Converts RESP response from Redis + * [ + * ['name', 'consumer-1', 'pending', 0, 'idle', 258741], + * ['name', 'consumer-2', 'pending', 0, 'idle', 258741], + * ... + * ] + * + * to DTO + * + * [ + * { + * name: 'consumer-1', + * pending: 0, + * idle: 258741, + * }, + * { + * name: 'consumer-2', + * pending: 0, + * idle: 258741, + * }, + * ... + * ] + * @param reply + */ + static formatReplyToDto(reply: Array>): ConsumerDto[] { + return reply.map(ConsumerService.formatArrayToDto); + } + + /** + * Format single reply entry to DTO + * @param entry + */ + static formatArrayToDto(entry: Array): ConsumerDto { + if (!entry?.length) { + return null; + } + + const entryObj = convertStringsArrayToObject(entry as string[]); + + return { + name: entryObj['name'], + pending: entryObj['pending'], + idle: entryObj['idle'], + }; + } + + /** + * Converts RESP response from Redis + * [ + * ['1567352639-0', 'consumer-1', 258741, 2], + * ... + * ] + * + * to DTO + * + * [ + * { + * id: '1567352639-0', + * name: 'consumer-1', + * idle: 258741, + * delivered: 2, + * }, + * ... + * ] + * @param reply + */ + static formatReplyToPendingEntriesDto(reply: Array>): PendingEntryDto[] { + return reply.map(ConsumerService.formatArrayToPendingEntryDto); + } + + /** + * Format single reply entry to DTO + * @param entry + */ + static formatArrayToPendingEntryDto(entry: Array): PendingEntryDto { + if (!entry?.length) { + return null; + } + + return { + id: `${entry[0]}`, + consumerName: `${entry[1]}`, + idle: +entry[2], + delivered: +entry[3], + }; + } +} diff --git a/redisinsight/api/src/modules/cli/utils/getUnsupportedCommands.spec.ts b/redisinsight/api/src/modules/cli/utils/getUnsupportedCommands.spec.ts index 5617d3eb8c..887f5963ca 100644 --- a/redisinsight/api/src/modules/cli/utils/getUnsupportedCommands.spec.ts +++ b/redisinsight/api/src/modules/cli/utils/getUnsupportedCommands.spec.ts @@ -2,7 +2,7 @@ import { getUnsupportedCommands } from './getUnsupportedCommands'; describe('cli unsupported commands', () => { it('should return correct list', () => { - const expectedResult = ['monitor', 'subscribe', 'psubscribe', 'sync', 'psync', 'script debug']; + const expectedResult = ['monitor', 'subscribe', 'psubscribe', 'ssubscribe', 'sync', 'psync', 'script debug']; expect(getUnsupportedCommands()).toEqual(expectedResult); }); diff --git a/redisinsight/api/src/modules/cli/utils/getUnsupportedCommands.ts b/redisinsight/api/src/modules/cli/utils/getUnsupportedCommands.ts index 75fed48aa0..119cf4da1a 100644 --- a/redisinsight/api/src/modules/cli/utils/getUnsupportedCommands.ts +++ b/redisinsight/api/src/modules/cli/utils/getUnsupportedCommands.ts @@ -6,6 +6,7 @@ export enum CliToolUnsupportedCommands { Monitor = 'monitor', Subscribe = 'subscribe', PSubscribe = 'psubscribe', + SSubscribe = 'ssubscribe', Sync = 'sync', PSync = 'psync', ScriptDebug = 'script debug', diff --git a/redisinsight/api/src/modules/core/models/server-provider.interface.ts b/redisinsight/api/src/modules/core/models/server-provider.interface.ts index 71d1e4bc1f..fe231a125f 100644 --- a/redisinsight/api/src/modules/core/models/server-provider.interface.ts +++ b/redisinsight/api/src/modules/core/models/server-provider.interface.ts @@ -6,6 +6,14 @@ export enum BuildType { DockerOnPremise = 'DOCKER_ON_PREMISE', } +export enum AppType { + RedisStackWeb = 'REDIS_STACK_WEB', + RedisStackApp = 'REDIS_STACK_ELECTRON', + Electron = 'ELECTRON', + Docker = 'DOCKER', + Unknown = 'UNKNOWN', +} + export interface IServerProvider { getInfo(): Promise; } diff --git a/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.spec.ts b/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.spec.ts index 26ff9a3c40..d947581546 100644 --- a/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.spec.ts +++ b/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.spec.ts @@ -94,7 +94,7 @@ describe('ServerOnPremiseService', () => { expect(eventEmitter.emit).toHaveBeenNthCalledWith( 1, AppAnalyticsEvents.Initialize, - { anonymousId: mockServerEntity.id, sessionId }, + { anonymousId: mockServerEntity.id, sessionId, appType: SERVER_CONFIG.buildType }, ); expect(eventEmitter.emit).toHaveBeenNthCalledWith( 2, @@ -113,7 +113,7 @@ describe('ServerOnPremiseService', () => { expect(eventEmitter.emit).toHaveBeenNthCalledWith( 1, AppAnalyticsEvents.Initialize, - { anonymousId: mockServerEntity.id, sessionId }, + { anonymousId: mockServerEntity.id, sessionId, appType: SERVER_CONFIG.buildType }, ); expect(eventEmitter.emit).toHaveBeenNthCalledWith( 2, @@ -140,6 +140,7 @@ describe('ServerOnPremiseService', () => { appVersion: SERVER_CONFIG.appVersion, osPlatform: process.platform, buildType: SERVER_CONFIG.buildType, + appType: SERVER_CONFIG.buildType, encryptionStrategies: [ EncryptionStrategy.PLAIN, EncryptionStrategy.KEYTAR, diff --git a/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.ts b/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.ts index 0a7e135106..c48fb796a9 100644 --- a/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.ts +++ b/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.ts @@ -1,16 +1,11 @@ -import { - Injectable, - InternalServerErrorException, - Logger, - OnApplicationBootstrap, -} from '@nestjs/common'; +import { Injectable, InternalServerErrorException, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import config from 'src/utils/config'; import { AppAnalyticsEvents } from 'src/constants/app-events'; import { TelemetryEvents } from 'src/constants/telemetry-events'; import { GetServerInfoResponse } from 'src/dto/server.dto'; import { ServerRepository } from 'src/modules/core/repositories/server.repository'; -import { IServerProvider } from 'src/modules/core/models/server-provider.interface'; +import { AppType, BuildType, IServerProvider } from 'src/modules/core/models/server-provider.interface'; import { ServerInfoNotFoundException } from 'src/constants/exceptions'; import { EncryptionService } from 'src/modules/core/encryption/encryption.service'; @@ -49,7 +44,11 @@ implements OnApplicationBootstrap, IServerProvider { // Create default server info on first application launch serverInfo = this.repository.create({}); await this.repository.save(serverInfo); - this.eventEmitter.emit(AppAnalyticsEvents.Initialize, { anonymousId: serverInfo.id, sessionId: this.sessionId }); + this.eventEmitter.emit(AppAnalyticsEvents.Initialize, { + anonymousId: serverInfo.id, + sessionId: this.sessionId, + appType: this.getAppType(SERVER_CONFIG.buildType), + }); this.eventEmitter.emit(AppAnalyticsEvents.Track, { event: TelemetryEvents.ApplicationFirstStart, eventData: { @@ -61,7 +60,11 @@ implements OnApplicationBootstrap, IServerProvider { }); } else { this.logger.log('Application started.'); - this.eventEmitter.emit(AppAnalyticsEvents.Initialize, { anonymousId: serverInfo.id, sessionId: this.sessionId }); + this.eventEmitter.emit(AppAnalyticsEvents.Initialize, { + anonymousId: serverInfo.id, + sessionId: this.sessionId, + appType: this.getAppType(SERVER_CONFIG.buildType), + }); this.eventEmitter.emit(AppAnalyticsEvents.Track, { event: TelemetryEvents.ApplicationStarted, eventData: { @@ -90,6 +93,7 @@ implements OnApplicationBootstrap, IServerProvider { appVersion: SERVER_CONFIG.appVersion, osPlatform: process.platform, buildType: SERVER_CONFIG.buildType, + appType: this.getAppType(SERVER_CONFIG.buildType), encryptionStrategies: await this.encryptionService.getAvailableEncryptionStrategies(), fixedDatabaseId: REDIS_STACK_CONFIG?.id, }; @@ -100,4 +104,17 @@ implements OnApplicationBootstrap, IServerProvider { throw new InternalServerErrorException(); } } + + getAppType(buildType: string): AppType { + switch (buildType) { + case BuildType.DockerOnPremise: + return AppType.Docker; + case BuildType.Electron: + return AppType.Electron; + case BuildType.RedisStack: + return AppType.RedisStackWeb; + default: + return AppType.Unknown; + } + } } diff --git a/redisinsight/api/src/modules/core/services/analytics/analytics.service.spec.ts b/redisinsight/api/src/modules/core/services/analytics/analytics.service.spec.ts index 1d5bf0986f..dfb018e7ab 100644 --- a/redisinsight/api/src/modules/core/services/analytics/analytics.service.spec.ts +++ b/redisinsight/api/src/modules/core/services/analytics/analytics.service.spec.ts @@ -6,6 +6,7 @@ import { AnalyticsService, NON_TRACKING_ANONYMOUS_ID, } from './analytics.service'; +import { AppType } from 'src/modules/core/models/server-provider.interface'; let mockAnalyticsTrack; jest.mock( @@ -59,7 +60,7 @@ describe('AnalyticsService', () => { describe('initialize', () => { it('should set anonymousId', () => { - service.initialize({ anonymousId: mockAnonymousId, sessionId }); + service.initialize({ anonymousId: mockAnonymousId, sessionId, appType: AppType.Electron }); const anonymousId = service.getAnonymousId(); @@ -70,7 +71,7 @@ describe('AnalyticsService', () => { describe('sendEvent', () => { beforeEach(() => { mockAnalyticsTrack = jest.fn(); - service.initialize({ anonymousId: mockAnonymousId, sessionId }); + service.initialize({ anonymousId: mockAnonymousId, sessionId, appType: AppType.Electron }); }); it('should send event with anonymousId if permission are granted', async () => { settingsService.getSettings = jest @@ -87,7 +88,9 @@ describe('AnalyticsService', () => { anonymousId: mockAnonymousId, integrations: { Amplitude: { session_id: sessionId } }, event: TelemetryEvents.ApplicationStarted, - properties: {}, + properties: { + buildType: AppType.Electron, + }, }); }); it('should not send event if permission are not granted', async () => { @@ -118,7 +121,9 @@ describe('AnalyticsService', () => { anonymousId: mockAnonymousId, integrations: { Amplitude: { session_id: sessionId } }, event: TelemetryEvents.ApplicationStarted, - properties: {}, + properties: { + buildType: AppType.Electron, + }, }); }); }); diff --git a/redisinsight/api/src/modules/core/services/analytics/analytics.service.ts b/redisinsight/api/src/modules/core/services/analytics/analytics.service.ts index 98c48207a6..bf4f747fc4 100644 --- a/redisinsight/api/src/modules/core/services/analytics/analytics.service.ts +++ b/redisinsight/api/src/modules/core/services/analytics/analytics.service.ts @@ -18,6 +18,7 @@ export interface ITelemetryEvent { export interface ITelemetryInitEvent { anonymousId: string; sessionId: number; + appType: string; } @Injectable() @@ -26,6 +27,8 @@ export class AnalyticsService { private sessionId: number = -1; + private appType: string = 'unknown'; + private analytics; constructor( @@ -39,9 +42,10 @@ export class AnalyticsService { @OnEvent(AppAnalyticsEvents.Initialize) public initialize(payload: ITelemetryInitEvent) { - const { anonymousId, sessionId } = payload; + const { anonymousId, sessionId, appType } = payload; this.sessionId = sessionId; this.anonymousId = anonymousId; + this.appType = appType; this.analytics = new Analytics(ANALYTICS_CONFIG.writeKey); } @@ -68,6 +72,7 @@ export class AnalyticsService { event, properties: { ...eventData, + buildType: this.appType, }, }); } diff --git a/redisinsight/api/src/modules/core/services/redis/redis.service.spec.ts b/redisinsight/api/src/modules/core/services/redis/redis.service.spec.ts index 261e9b1ee5..3260a4850a 100644 --- a/redisinsight/api/src/modules/core/services/redis/redis.service.spec.ts +++ b/redisinsight/api/src/modules/core/services/redis/redis.service.spec.ts @@ -25,6 +25,7 @@ jest.mock('ioredis'); const mockTlsConfigResult: ConnectionOptions = { rejectUnauthorized: true, + servername: mockStandaloneDatabaseEntity.tlsServername, checkServerIdentity: () => undefined, ca: [mockCaCertDto.cert], key: mockClientCertDto.key, diff --git a/redisinsight/api/src/modules/instances/dto/database-overview.dto.ts b/redisinsight/api/src/modules/instances/dto/database-overview.dto.ts index df938528bf..246a6ec16b 100644 --- a/redisinsight/api/src/modules/instances/dto/database-overview.dto.ts +++ b/redisinsight/api/src/modules/instances/dto/database-overview.dto.ts @@ -19,6 +19,12 @@ export class DatabaseOverview { }) totalKeys?: number; + @ApiPropertyOptional({ + description: 'Nested object with total number of keys per logical database', + type: Number, + }) + totalKeysPerDb?: Record; + @ApiPropertyOptional({ description: 'Median for connected clients in the all shards', type: Number, diff --git a/redisinsight/api/src/modules/profiler/models/log-file.ts b/redisinsight/api/src/modules/profiler/models/log-file.ts index 04fb389ca1..a4f34b66c5 100644 --- a/redisinsight/api/src/modules/profiler/models/log-file.ts +++ b/redisinsight/api/src/modules/profiler/models/log-file.ts @@ -124,7 +124,7 @@ export class LogFile { this.writeStream?.close(); this.writeStream = null; const size = this.getFileSize(); - fs.unlink(this.filePath); + fs.unlinkSync(this.filePath); this.analyticsEvents.get(TelemetryEvents.ProfilerLogDeleted)(this.instanceId, size); } catch (e) { diff --git a/redisinsight/api/src/modules/pub-sub/constants/index.ts b/redisinsight/api/src/modules/pub-sub/constants/index.ts new file mode 100644 index 0000000000..666e2fdfb7 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/constants/index.ts @@ -0,0 +1,28 @@ +export enum PubSubClientEvents { + Subscribe = 'subscribe', + Unsubscribe = 'unsubscribe', +} + +export enum PubSubServerEvents { + Exception = 'exception', +} + +export enum SubscriptionType { + Subscribe = 's', + PSubscribe = 'p', + SSubscribe = 'ss', +} + +export enum RedisClientStatus { + Connecting = 'connecting', + Connected = 'connected', + Error = 'error', + End = 'end', +} + +export enum RedisClientEvents { + Connected = 'connected', + ConnectionError = 'connection_error', + Message = 'message', + End = 'end', +} diff --git a/redisinsight/api/src/modules/pub-sub/decorators/client.decorator.ts b/redisinsight/api/src/modules/pub-sub/decorators/client.decorator.ts new file mode 100644 index 0000000000..2c78dd897e --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/decorators/client.decorator.ts @@ -0,0 +1,11 @@ +import { get } from 'lodash'; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { UserClient } from 'src/modules/pub-sub/model/user-client'; + +export const Client = createParamDecorator( + (data: unknown, ctx: ExecutionContext): UserClient => { + const socket = ctx.switchToWs().getClient(); + + return new UserClient(socket.id, socket, get(socket, 'handshake.query.instanceId')); + }, +); diff --git a/redisinsight/api/src/modules/pub-sub/dto/index.ts b/redisinsight/api/src/modules/pub-sub/dto/index.ts new file mode 100644 index 0000000000..088b5a1e98 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/dto/index.ts @@ -0,0 +1,3 @@ +export * from './subscribe.dto'; +export * from './subscription.dto'; +export * from './messages.response'; diff --git a/redisinsight/api/src/modules/pub-sub/dto/messages.response.ts b/redisinsight/api/src/modules/pub-sub/dto/messages.response.ts new file mode 100644 index 0000000000..e02ea99aa4 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/dto/messages.response.ts @@ -0,0 +1,7 @@ +import { IMessage } from 'src/modules/pub-sub/interfaces/message.interface'; + +export class MessagesResponse { + messages: IMessage[]; + + count: number; +} diff --git a/redisinsight/api/src/modules/pub-sub/dto/publish.dto.ts b/redisinsight/api/src/modules/pub-sub/dto/publish.dto.ts new file mode 100644 index 0000000000..abe3ee4495 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/dto/publish.dto.ts @@ -0,0 +1,25 @@ +import { + IsDefined, + IsString, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class PublishDto { + @ApiProperty({ + type: String, + description: 'Message to send', + example: '{"hello":"world"}', + }) + @IsDefined() + @IsString() + message: string; + + @ApiProperty({ + type: String, + description: 'Chanel name', + example: 'channel-1', + }) + @IsDefined() + @IsString() + channel: string; +} diff --git a/redisinsight/api/src/modules/pub-sub/dto/publish.response.ts b/redisinsight/api/src/modules/pub-sub/dto/publish.response.ts new file mode 100644 index 0000000000..85b3fbbd39 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/dto/publish.response.ts @@ -0,0 +1,3 @@ +export class PublishResponse { + affected: number; +} diff --git a/redisinsight/api/src/modules/pub-sub/dto/subscribe.dto.ts b/redisinsight/api/src/modules/pub-sub/dto/subscribe.dto.ts new file mode 100644 index 0000000000..e1f32af388 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/dto/subscribe.dto.ts @@ -0,0 +1,13 @@ +import { + ArrayNotEmpty, IsArray, ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { SubscriptionDto } from './subscription.dto'; + +export class SubscribeDto { + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => SubscriptionDto) + subscriptions: SubscriptionDto[]; +} diff --git a/redisinsight/api/src/modules/pub-sub/dto/subscription.dto.ts b/redisinsight/api/src/modules/pub-sub/dto/subscription.dto.ts new file mode 100644 index 0000000000..d3695113a7 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/dto/subscription.dto.ts @@ -0,0 +1,12 @@ +import { SubscriptionType } from 'src/modules/pub-sub/constants'; +import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; + +export class SubscriptionDto { + @IsNotEmpty() + @IsString() + channel: string; + + @IsNotEmpty() + @IsEnum(SubscriptionType) + type: SubscriptionType; +} diff --git a/redisinsight/api/src/modules/pub-sub/errors/pub-sub-ws.exception.ts b/redisinsight/api/src/modules/pub-sub/errors/pub-sub-ws.exception.ts new file mode 100644 index 0000000000..9cd10c52f3 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/errors/pub-sub-ws.exception.ts @@ -0,0 +1,26 @@ +import { HttpException } from '@nestjs/common'; +import { isString } from 'lodash'; + +export class PubSubWsException extends Error { + status: number; + + name: string; + + constructor(err: Error | string) { + super(); + this.status = 500; + this.message = 'Internal server error'; + this.name = this.constructor.name; + + if (isString(err)) { + this.message = err; + } else if (err instanceof HttpException) { + this.message = (err.getResponse())['message']; + this.status = err.getStatus(); + this.name = err.constructor.name; + } else if (err instanceof Error) { + this.message = err.message; + this.name = 'Error'; + } + } +} diff --git a/redisinsight/api/src/modules/pub-sub/filters/ack-ws-exception.filter.ts b/redisinsight/api/src/modules/pub-sub/filters/ack-ws-exception.filter.ts new file mode 100644 index 0000000000..77f853633d --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/filters/ack-ws-exception.filter.ts @@ -0,0 +1,18 @@ +import { + ArgumentsHost, Catch, HttpException, +} from '@nestjs/common'; +import { PubSubWsException } from 'src/modules/pub-sub/errors/pub-sub-ws.exception'; + +@Catch() +export class AckWsExceptionFilter { + public catch(exception: HttpException, host: ArgumentsHost) { + const callback = host.getArgByIndex(2); + this.handleError(callback, exception); + } + + public handleError(callback: any, exception: Error) { + if (callback && typeof callback === 'function') { + callback({ status: 'error', error: new PubSubWsException(exception) }); + } + } +} diff --git a/redisinsight/api/src/modules/pub-sub/interfaces/message.interface.ts b/redisinsight/api/src/modules/pub-sub/interfaces/message.interface.ts new file mode 100644 index 0000000000..5947222651 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/interfaces/message.interface.ts @@ -0,0 +1,7 @@ +export interface IMessage { + message: string; + + channel: string; + + time: number; +} diff --git a/redisinsight/api/src/modules/pub-sub/interfaces/subscription.interface.ts b/redisinsight/api/src/modules/pub-sub/interfaces/subscription.interface.ts new file mode 100644 index 0000000000..f266a1920a --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/interfaces/subscription.interface.ts @@ -0,0 +1,16 @@ +import * as IORedis from 'ioredis'; +import { IMessage } from 'src/modules/pub-sub/interfaces/message.interface'; + +export interface ISubscription { + getId(): string; + + getChannel(): string; + + getType(): string; + + pushMessage(message: IMessage): void; + + subscribe(client: IORedis.Redis | IORedis.Cluster): Promise; + + unsubscribe(client: IORedis.Redis | IORedis.Cluster): Promise; +} diff --git a/redisinsight/api/src/modules/pub-sub/model/abstract.subscription.ts b/redisinsight/api/src/modules/pub-sub/model/abstract.subscription.ts new file mode 100644 index 0000000000..97ce4cac06 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/model/abstract.subscription.ts @@ -0,0 +1,73 @@ +import { debounce } from 'lodash'; +import { SubscriptionType } from 'src/modules/pub-sub/constants'; +import { UserClient } from 'src/modules/pub-sub/model/user-client'; +import { MessagesResponse, SubscriptionDto } from 'src/modules/pub-sub/dto'; +import { ISubscription } from 'src/modules/pub-sub/interfaces/subscription.interface'; +import { IMessage } from 'src/modules/pub-sub/interfaces/message.interface'; +import * as IORedis from 'ioredis'; + +const EMIT_WAIT = 30; +const EMIT_MAX_WAIT = 100; +const MESSAGES_MAX = 5000; + +export abstract class AbstractSubscription implements ISubscription { + protected readonly id: string; + + protected readonly userClient: UserClient; + + protected readonly debounce: any; + + protected readonly channel: string; + + protected readonly type: SubscriptionType; + + protected messages: IMessage[] = []; + + constructor(userClient: UserClient, dto: SubscriptionDto) { + this.userClient = userClient; + this.channel = dto.channel; + this.type = dto.type; + this.id = `${this.type}:${this.channel}`; + this.debounce = debounce(() => { + if (this.messages.length) { + this.userClient.getSocket() + .emit(this.id, { + messages: this.messages.slice(0, MESSAGES_MAX), + count: this.messages.length, + } as MessagesResponse); + this.messages = []; + } + }, EMIT_WAIT, { + maxWait: EMIT_MAX_WAIT, + }); + } + + getId() { + return this.id; + } + + getChannel() { + return this.channel; + } + + getType() { + return this.type; + } + + abstract subscribe(client: IORedis.Redis | IORedis.Cluster): Promise; + + abstract unsubscribe(client: IORedis.Redis | IORedis.Cluster): Promise; + + pushMessage(message: IMessage) { + this.messages.push(message); + + this.debounce(); + } + + toString() { + return `${this.constructor.name}:${JSON.stringify({ + id: this.id, + mL: this.messages.length, + })}`; + } +} diff --git a/redisinsight/api/src/modules/pub-sub/model/pattern.subscription.ts b/redisinsight/api/src/modules/pub-sub/model/pattern.subscription.ts new file mode 100644 index 0000000000..23a381dd44 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/model/pattern.subscription.ts @@ -0,0 +1,12 @@ +import { AbstractSubscription } from 'src/modules/pub-sub/model/abstract.subscription'; +import * as IORedis from 'ioredis'; + +export class PatternSubscription extends AbstractSubscription { + async subscribe(client: IORedis.Redis | IORedis.Cluster): Promise { + await client.psubscribe(this.channel); + } + + async unsubscribe(client: IORedis.Redis | IORedis.Cluster): Promise { + await client.punsubscribe(this.channel); + } +} diff --git a/redisinsight/api/src/modules/pub-sub/model/redis-client.spec.ts b/redisinsight/api/src/modules/pub-sub/model/redis-client.spec.ts new file mode 100644 index 0000000000..4c3916e957 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/model/redis-client.spec.ts @@ -0,0 +1,133 @@ +import * as Redis from 'ioredis'; +import { RedisClient } from 'src/modules/pub-sub/model/redis-client'; +import { RedisClientEvents, RedisClientStatus } from 'src/modules/pub-sub/constants'; + +const getRedisClientFn = jest.fn(); + +const nodeClient = Object.create(Redis.prototype); +nodeClient.subscribe = jest.fn(); +nodeClient.psubscribe = jest.fn(); +nodeClient.unsubscribe = jest.fn(); +nodeClient.punsubscribe = jest.fn(); +nodeClient.status = 'ready'; +nodeClient.disconnect = jest.fn(); +nodeClient.quit = jest.fn(); + +describe('RedisClient', () => { + let redisClient: RedisClient; + + beforeEach(() => { + jest.resetAllMocks(); + redisClient = new RedisClient('databaseId', getRedisClientFn); + getRedisClientFn.mockResolvedValue(nodeClient); + nodeClient.subscribe.mockResolvedValue('OK'); + nodeClient.psubscribe.mockResolvedValue('OK'); + }); + + describe('getClient', () => { + let connectSpy; + + beforeEach(() => { + connectSpy = jest.spyOn(redisClient as any, 'connect'); + }); + + it('should connect and return client by default', async () => { + expect(await redisClient.getClient()).toEqual(nodeClient); + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(redisClient['status']).toEqual(RedisClientStatus.Connected); + }); + it('should wait until first attempt of connection finish with success', async () => { + redisClient.getClient().then().catch(); + expect(redisClient['status']).toEqual(RedisClientStatus.Connecting); + expect(await redisClient.getClient()).toEqual(nodeClient); + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(redisClient['status']).toEqual(RedisClientStatus.Connected); + }); + it('should wait until first attempt of connection finish with error', async () => { + try { + getRedisClientFn.mockRejectedValueOnce(new Error('Connection error')); + redisClient.getClient().then().catch(() => {}); + expect(redisClient['status']).toEqual(RedisClientStatus.Connecting); + expect(await redisClient.getClient()).toEqual(nodeClient); + fail(); + } catch (e) { + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(redisClient['status']).toEqual(RedisClientStatus.Error); + } + }); + it('should return existing connection when status connected', async () => { + expect(await redisClient.getClient()).toEqual(nodeClient); + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(redisClient['status']).toEqual(RedisClientStatus.Connected); + expect(await redisClient.getClient()).toEqual(nodeClient); + expect(connectSpy).toHaveBeenCalledTimes(1); + }); + it('should return create new connection when status end or error', async () => { + expect(await redisClient.getClient()).toEqual(nodeClient); + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(redisClient['status']).toEqual(RedisClientStatus.Connected); + redisClient['status'] = RedisClientStatus.Error; + expect(await redisClient.getClient()).toEqual(nodeClient); + expect(connectSpy).toHaveBeenCalledTimes(2); + expect(redisClient['status']).toEqual(RedisClientStatus.Connected); + redisClient['status'] = RedisClientStatus.End; + expect(await redisClient.getClient()).toEqual(nodeClient); + expect(connectSpy).toHaveBeenCalledTimes(3); + expect(redisClient['status']).toEqual(RedisClientStatus.Connected); + }); + }); + + describe('connect', () => { + it('should connect and emit connected event', async () => { + expect(await new Promise((res) => { + redisClient['connect'](); + redisClient.on(RedisClientEvents.Connected, res); + })).toEqual(nodeClient); + }); + it('should emit message event (message source)', async () => { + await redisClient['connect'](); + const [id, message] = await new Promise((res) => { + redisClient.on('message', (i, m) => res([i, m])); + nodeClient.emit('message', 'channel-a', 'message-a'); + }); + + expect(id).toEqual('s:channel-a'); + expect(message.channel).toEqual('channel-a'); + expect(message.message).toEqual('message-a'); + }); + it('should emit message event (pmessage source)', async () => { + await redisClient['connect'](); + const [id, message] = await new Promise((res) => { + redisClient.on('message', (i, m) => res([i, m])); + nodeClient.emit('pmessage', '*', 'channel-aa', 'message-aa'); + }); + expect(id).toEqual('p:*'); + expect(message.channel).toEqual('channel-aa'); + expect(message.message).toEqual('message-aa'); + }); + it('should emit end event', async () => { + await redisClient['connect'](); + await new Promise((res) => { + redisClient.on('end', () => { + res(null); + }); + + nodeClient.emit('end'); + }); + }); + }); + + describe('destroy', () => { + it('should remove all listeners, disconnect, set client to null and emit end event', async () => { + const removeAllListenersSpy = jest.spyOn(nodeClient, 'removeAllListeners'); + + await redisClient['connect'](); + redisClient.destroy(); + + expect(redisClient['client']).toEqual(null); + expect(redisClient['status']).toEqual(RedisClientStatus.End); + expect(removeAllListenersSpy).toHaveBeenCalled(); + expect(nodeClient.quit).toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/pub-sub/model/redis-client.ts b/redisinsight/api/src/modules/pub-sub/model/redis-client.ts new file mode 100644 index 0000000000..a11be02c0c --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/model/redis-client.ts @@ -0,0 +1,110 @@ +import * as IORedis from 'ioredis'; +import { Logger } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { RedisClientEvents, RedisClientStatus } from 'src/modules/pub-sub/constants'; + +export class RedisClient extends EventEmitter2 { + private logger: Logger = new Logger('RedisClient'); + + private client: IORedis.Redis | IORedis.Cluster; + + private readonly databaseId: string; + + private readonly connectFn: () => Promise; + + private status: RedisClientStatus; + + constructor( + databaseId: string, + connectFn: () => Promise, + ) { + super(); + this.databaseId = databaseId; + this.connectFn = connectFn; + } + + /** + * Get existing client or wait until previous attempt fulfill or initiate new connection attempt + * based on current status + */ + async getClient(): Promise { + try { + this.logger.debug(`Get client ${this}`); + switch (this.status) { + case RedisClientStatus.Connected: + return this.client; + case RedisClientStatus.Connecting: + // wait until connect or error + break; + case RedisClientStatus.Error: + case RedisClientStatus.End: + default: + await this.connect(); + return this.client; + } + + return new Promise((resolve, reject) => { + this.once(RedisClientEvents.Connected, resolve); + this.once(RedisClientEvents.ConnectionError, reject); + }); + } catch (e) { + this.logger.error('Unable to connect to Redis', e); + this.status = RedisClientStatus.Error; + this.emit(RedisClientEvents.ConnectionError, e); + throw e; + } + } + + /** + * Connects to redis and change current status to Connected + * Also emit Connected event after success + * Also subscribe to needed channels + * @private + */ + private async connect() { + this.status = RedisClientStatus.Connecting; + this.client = await this.connectFn(); + this.status = RedisClientStatus.Connected; + this.emit(RedisClientEvents.Connected, this.client); + + this.client.on('message', (channel: string, message: string) => { + this.emit(RedisClientEvents.Message, `s:${channel}`, { + channel, + message, + time: Date.now(), + }); + }); + + this.client.on('pmessage', (pattern: string, channel: string, message: string) => { + this.emit(RedisClientEvents.Message, `p:${pattern}`, { + channel, + message, + time: Date.now(), + }); + }); + + this.client.on('end', () => { + this.status = RedisClientStatus.End; + this.emit(RedisClientEvents.End); + }); + } + + /** + * Unsubscribe all listeners and disconnect + * Remove client and set current state to End + */ + destroy() { + this.client?.removeAllListeners(); + this.client?.quit(); + this.client = null; + this.status = RedisClientStatus.End; + } + + toString() { + return `RedisClient:${JSON.stringify({ + databaseId: this.databaseId, + status: this.status, + clientStatus: this.client?.status, + })}`; + } +} diff --git a/redisinsight/api/src/modules/pub-sub/model/simple.subscription.ts b/redisinsight/api/src/modules/pub-sub/model/simple.subscription.ts new file mode 100644 index 0000000000..12893d2791 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/model/simple.subscription.ts @@ -0,0 +1,12 @@ +import { AbstractSubscription } from 'src/modules/pub-sub/model/abstract.subscription'; +import * as IORedis from 'ioredis'; + +export class SimpleSubscription extends AbstractSubscription { + async subscribe(client: IORedis.Redis | IORedis.Cluster): Promise { + await client.subscribe(this.channel); + } + + async unsubscribe(client: IORedis.Redis | IORedis.Cluster): Promise { + await client.unsubscribe(this.channel); + } +} diff --git a/redisinsight/api/src/modules/pub-sub/model/user-client.ts b/redisinsight/api/src/modules/pub-sub/model/user-client.ts new file mode 100644 index 0000000000..42de3b7c75 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/model/user-client.ts @@ -0,0 +1,27 @@ +import { Socket } from 'socket.io'; + +export class UserClient { + private readonly socket: Socket; + + private readonly id: string; + + private readonly databaseId: string; + + constructor(id: string, socket: Socket, databaseId: string) { + this.id = id; + this.socket = socket; + this.databaseId = databaseId; + } + + getId() { + return this.id; + } + + getDatabaseId() { + return this.databaseId; + } + + getSocket() { + return this.socket; + } +} diff --git a/redisinsight/api/src/modules/pub-sub/model/user-session.spec.ts b/redisinsight/api/src/modules/pub-sub/model/user-session.spec.ts new file mode 100644 index 0000000000..9f2cacbcf1 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/model/user-session.spec.ts @@ -0,0 +1,126 @@ +import * as Redis from 'ioredis'; +import { mockSocket } from 'src/__mocks__'; +import { UserSession } from 'src/modules/pub-sub/model/user-session'; +import { UserClient } from 'src/modules/pub-sub/model/user-client'; +import { RedisClient } from 'src/modules/pub-sub/model/redis-client'; +import { SimpleSubscription } from 'src/modules/pub-sub/model/simple.subscription'; +import { SubscriptionType } from 'src/modules/pub-sub/constants'; +import { PatternSubscription } from 'src/modules/pub-sub/model/pattern.subscription'; + +const getRedisClientFn = jest.fn(); + +const nodeClient = Object.create(Redis.prototype); +nodeClient.subscribe = jest.fn(); +nodeClient.psubscribe = jest.fn(); +nodeClient.unsubscribe = jest.fn(); +nodeClient.punsubscribe = jest.fn(); +nodeClient.status = 'ready'; +nodeClient.disconnect = jest.fn(); +nodeClient.quit = jest.fn(); + +const mockUserClient = new UserClient('socketId', mockSocket, 'databaseId'); + +const mockRedisClient = new RedisClient('databaseId', getRedisClientFn); + +const mockSubscriptionDto = { + channel: 'channel-a', + type: SubscriptionType.Subscribe, +}; + +const mockPSubscriptionDto = { + channel: 'channel-a', + type: SubscriptionType.PSubscribe, +}; + +const mockSubscription = new SimpleSubscription(mockUserClient, mockSubscriptionDto); +const mockPSubscription = new PatternSubscription(mockUserClient, mockPSubscriptionDto); + +const mockMessage = { + channel: 'channel-a', + message: 'message-a', + time: 1234567890, +}; + +describe('UserSession', () => { + let userSession: UserSession; + + beforeEach(() => { + jest.resetAllMocks(); + userSession = new UserSession(mockUserClient, mockRedisClient); + getRedisClientFn.mockResolvedValue(nodeClient); + nodeClient.subscribe.mockResolvedValue('OK'); + nodeClient.psubscribe.mockResolvedValue('OK'); + }); + + describe('subscribe', () => { + it('should subscribe to a channel', async () => { + expect(userSession['subscriptions'].size).toEqual(0); + await userSession.subscribe(mockSubscription); + expect(userSession['subscriptions'].size).toEqual(1); + await userSession.subscribe(mockSubscription); + expect(userSession['subscriptions'].size).toEqual(1); + expect(userSession['subscriptions'].get(mockSubscription.getId())).toEqual(mockSubscription); + await userSession.subscribe(mockPSubscription); + expect(userSession['subscriptions'].size).toEqual(2); + await userSession.subscribe(mockPSubscription); + expect(userSession['subscriptions'].size).toEqual(2); + expect(userSession['subscriptions'].get(mockPSubscription.getId())).toEqual(mockPSubscription); + }); + }); + + describe('unsubscribe', () => { + it('should unsubscribe from a channel', async () => { + expect(userSession['subscriptions'].size).toEqual(0); + await userSession.subscribe(mockSubscription); + expect(userSession['subscriptions'].size).toEqual(1); + await userSession.subscribe(mockPSubscription); + expect(userSession['subscriptions'].size).toEqual(2); + await userSession.unsubscribe(mockSubscription); + expect(userSession['subscriptions'].size).toEqual(1); + await userSession.unsubscribe(mockSubscription); + expect(userSession['subscriptions'].size).toEqual(1); + await userSession.unsubscribe(mockPSubscription); + expect(userSession['subscriptions'].size).toEqual(0); + await userSession.unsubscribe(mockPSubscription); + expect(userSession['subscriptions'].size).toEqual(0); + }); + }); + + describe('handleMessage', () => { + let handleSimpleSpy; + let handlePatternSpy; + + beforeEach(async () => { + handleSimpleSpy = jest.spyOn(mockSubscription, 'pushMessage'); + handlePatternSpy = jest.spyOn(mockPSubscription, 'pushMessage'); + await userSession.subscribe(mockSubscription); + await userSession.subscribe(mockPSubscription); + }); + it('should handle message by particular subscription', async () => { + userSession.handleMessage('id', mockMessage); + expect(handleSimpleSpy).toHaveBeenCalledTimes(0); + expect(handlePatternSpy).toHaveBeenCalledTimes(0); + userSession.handleMessage(mockSubscription.getId(), mockMessage); + expect(handleSimpleSpy).toHaveBeenCalledTimes(1); + expect(handlePatternSpy).toHaveBeenCalledTimes(0); + userSession.handleMessage(mockPSubscription.getId(), mockMessage); + userSession.handleMessage(mockPSubscription.getId(), mockMessage); + expect(handleSimpleSpy).toHaveBeenCalledTimes(1); + expect(handlePatternSpy).toHaveBeenCalledTimes(2); + // wait until debounce process + await new Promise((res) => setTimeout(res, 200)); + }); + }); + + describe('handleDisconnect', () => { + beforeEach(async () => { + await userSession.subscribe(mockSubscription); + await userSession.subscribe(mockPSubscription); + }); + it('should handle message by particular subscription', async () => { + userSession.handleDisconnect(); + expect(userSession['subscriptions'].size).toEqual(0); + expect(nodeClient.quit).toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/pub-sub/model/user-session.ts b/redisinsight/api/src/modules/pub-sub/model/user-session.ts new file mode 100644 index 0000000000..93de7c8599 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/model/user-session.ts @@ -0,0 +1,127 @@ +import { RedisClient } from 'src/modules/pub-sub/model/redis-client'; +import { UserClient } from 'src/modules/pub-sub/model/user-client'; +import { ISubscription } from 'src/modules/pub-sub/interfaces/subscription.interface'; +import { IMessage } from 'src/modules/pub-sub/interfaces/message.interface'; +import { PubSubServerEvents, RedisClientEvents } from 'src/modules/pub-sub/constants'; +import { Logger } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { PubSubWsException } from 'src/modules/pub-sub/errors/pub-sub-ws.exception'; + +export class UserSession { + private readonly logger: Logger = new Logger('UserSession'); + + private readonly id: string; + + private readonly userClient: UserClient; + + private readonly redisClient: RedisClient; + + private subscriptions: Map = new Map(); + + constructor(userClient: UserClient, redisClient: RedisClient) { + this.id = userClient.getId(); + this.userClient = userClient; + this.redisClient = redisClient; + redisClient.on(RedisClientEvents.Message, this.handleMessage.bind(this)); + redisClient.on(RedisClientEvents.End, this.handleDisconnect.bind(this)); + } + + getId() { return this.id; } + + getUserClient() { return this.userClient; } + + getRedisClient() { return this.redisClient; } + + /** + * Subscribe to a Pub/Sub channel and create Redis client connection if needed + * Also add subscription to the subscriptions list + * @param subscription + */ + async subscribe(subscription: ISubscription) { + this.logger.debug(`Subscribe ${subscription} ${this}. Getting Redis client...`); + + const client = await this.redisClient?.getClient(); + + if (!client) { throw new Error('There is no Redis client initialized'); } + + if (!this.subscriptions.has(subscription.getId())) { + this.subscriptions.set(subscription.getId(), subscription); + this.logger.debug(`Subscribe to Redis ${subscription} ${this}`); + await subscription.subscribe(client); + } + } + + /** + * Unsubscribe from a channel and remove from the list of subscriptions + * Also destroy redis client when no subscriptions left + * @param subscription + */ + async unsubscribe(subscription: ISubscription) { + this.logger.debug(`Unsubscribe ${subscription} ${this}`); + + this.subscriptions.delete(subscription.getId()); + + const client = await this.redisClient?.getClient(); + + if (client) { + this.logger.debug(`Unsubscribe from Redis ${subscription} ${this}`); + await subscription.unsubscribe(client); + + if (!this.subscriptions.size) { + this.logger.debug(`Unsubscribe: Destroy RedisClient ${this}`); + this.redisClient.destroy(); + } + } + } + + /** + * Redirect message to a proper subscription from the list using id + * ID is generated in this way: "p:channelName" where "p" - is a type of subscription + * Subscription types: s - "subscribe", p - "psubscribe", ss - "ssubscribe" + * @param id + * @param message + */ + handleMessage(id: string, message: IMessage) { + const subscription = this.subscriptions.get(id); + + if (subscription) { + subscription.pushMessage(message); + } + } + + /** + * Handle socket disconnection + * In this case we need to destroy entire session and cascade destroy other models inside + * to be sure that there is no open connections left + */ + handleDisconnect() { + this.logger.debug(`Handle disconnect ${this}`); + + this.userClient.getSocket().emit( + PubSubServerEvents.Exception, + new PubSubWsException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), + ); + + this.destroy(); + } + + /** + * Reset subscriptions map and call and destroy Redis client + */ + destroy() { + this.logger.debug(`Destroy ${this}`); + + this.subscriptions = new Map(); + this.redisClient.destroy(); + + this.logger.debug(`Destroyed ${this}`); + } + + toString() { + return `UserSession:${JSON.stringify({ + id: this.id, + subscriptionsSize: this.subscriptions.size, + subscriptions: [...this.subscriptions.keys()], + })}`; + } +} diff --git a/redisinsight/api/src/modules/pub-sub/providers/redis-client.provider.spec.ts b/redisinsight/api/src/modules/pub-sub/providers/redis-client.provider.spec.ts new file mode 100644 index 0000000000..cc2c6f2762 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/providers/redis-client.provider.spec.ts @@ -0,0 +1,40 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { mockSocket } from 'src/__mocks__'; +import { UserClient } from 'src/modules/pub-sub/model/user-client'; +import { RedisClientProvider } from 'src/modules/pub-sub/providers/redis-client.provider'; +import { RedisService } from 'src/modules/core/services/redis/redis.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { RedisClient } from 'src/modules/pub-sub/model/redis-client'; + +const mockUserClient = new UserClient('socketId', mockSocket, 'databaseId'); + +describe('RedisClientProvider', () => { + let service: RedisClientProvider; + + beforeEach(async () => { + jest.resetAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RedisClientProvider, + { + provide: RedisService, + useFactory: () => ({}), + }, + { + provide: InstancesBusinessService, + useFactory: () => ({}), + }, + ], + }).compile(); + + service = await module.get(RedisClientProvider); + }); + + describe('createClient', () => { + it('should create redis client', async () => { + const redisClient = service.createClient(mockUserClient.getId()); + expect(redisClient).toBeInstanceOf(RedisClient); + }); + }); +}); diff --git a/redisinsight/api/src/modules/pub-sub/providers/redis-client.provider.ts b/redisinsight/api/src/modules/pub-sub/providers/redis-client.provider.ts new file mode 100644 index 0000000000..5de688a4b0 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/providers/redis-client.provider.ts @@ -0,0 +1,34 @@ +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { RedisService } from 'src/modules/core/services/redis/redis.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { AppTool } from 'src/models'; +import { withTimeout } from 'src/utils/promise-with-timeout'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import config from 'src/utils/config'; +import { RedisClient } from 'src/modules/pub-sub/model/redis-client'; + +const serverConfig = config.get('server'); + +@Injectable() +export class RedisClientProvider { + constructor( + private redisService: RedisService, + private instancesBusinessService: InstancesBusinessService, + ) {} + + createClient(databaseId: string): RedisClient { + return new RedisClient(databaseId, this.getConnectFn(databaseId)); + } + + private getConnectFn(databaseId: string) { + return () => withTimeout( + this.instancesBusinessService.connectToInstance( + databaseId, + AppTool.Common, + false, + ), + serverConfig.requestTimeout, + new ServiceUnavailableException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), + ); + } +} diff --git a/redisinsight/api/src/modules/pub-sub/providers/subscription.provider.spec.ts b/redisinsight/api/src/modules/pub-sub/providers/subscription.provider.spec.ts new file mode 100644 index 0000000000..bd0a8d7012 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/providers/subscription.provider.spec.ts @@ -0,0 +1,64 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockSocket, +} from 'src/__mocks__'; +import { UserClient } from 'src/modules/pub-sub/model/user-client'; +import { SubscriptionProvider } from 'src/modules/pub-sub/providers/subscription.provider'; +import { SubscriptionType } from 'src/modules/pub-sub/constants'; +import { SimpleSubscription } from 'src/modules/pub-sub/model/simple.subscription'; +import { PatternSubscription } from 'src/modules/pub-sub/model/pattern.subscription'; +import { BadRequestException } from '@nestjs/common'; + +const mockUserClient = new UserClient('socketId', mockSocket, 'databaseId'); + +const mockSubscriptionDto = { + channel: 'channel-a', + type: SubscriptionType.Subscribe, +}; + +const mockPSubscriptionDto = { + channel: 'channel-a', + type: SubscriptionType.PSubscribe, +}; + +const mockSSubscriptionDto = { + channel: 'channel-a', + type: SubscriptionType.SSubscribe, +}; + +describe('SubscriptionProvider', () => { + let service: SubscriptionProvider; + + beforeEach(async () => { + jest.resetAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SubscriptionProvider, + + ], + }).compile(); + + service = await module.get(SubscriptionProvider); + }); + + describe('createSubscription', () => { + it('should create simple subscription', async () => { + const subscription = service.createSubscription(mockUserClient, mockSubscriptionDto); + expect(subscription).toBeInstanceOf(SimpleSubscription); + }); + it('should create pattern subscription', async () => { + const subscription = service.createSubscription(mockUserClient, mockPSubscriptionDto); + expect(subscription).toBeInstanceOf(PatternSubscription); + }); + it('should throw error since shard subscription is not supported yet', async () => { + try { + service.createSubscription(mockUserClient, mockSSubscriptionDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual('Unsupported Subscription type'); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/pub-sub/providers/subscription.provider.ts b/redisinsight/api/src/modules/pub-sub/providers/subscription.provider.ts new file mode 100644 index 0000000000..b724c1aafb --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/providers/subscription.provider.ts @@ -0,0 +1,22 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { SubscriptionDto } from 'src/modules/pub-sub/dto'; +import { SubscriptionType } from 'src/modules/pub-sub/constants'; +import { UserClient } from 'src/modules/pub-sub/model/user-client'; +import { PatternSubscription } from 'src/modules/pub-sub/model/pattern.subscription'; +import { SimpleSubscription } from 'src/modules/pub-sub/model/simple.subscription'; +import { ISubscription } from 'src/modules/pub-sub/interfaces/subscription.interface'; + +@Injectable() +export class SubscriptionProvider { + createSubscription(userClient: UserClient, dto: SubscriptionDto): ISubscription { + switch (dto.type) { + case SubscriptionType.PSubscribe: + return new PatternSubscription(userClient, dto); + case SubscriptionType.Subscribe: + return new SimpleSubscription(userClient, dto); + case SubscriptionType.SSubscribe: + default: + throw new BadRequestException('Unsupported Subscription type'); + } + } +} diff --git a/redisinsight/api/src/modules/pub-sub/providers/user-session.provider.spec.ts b/redisinsight/api/src/modules/pub-sub/providers/user-session.provider.spec.ts new file mode 100644 index 0000000000..b6b64eb77b --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/providers/user-session.provider.spec.ts @@ -0,0 +1,67 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockSocket, + MockType, +} from 'src/__mocks__'; +import { UserSessionProvider } from 'src/modules/pub-sub/providers/user-session.provider'; +import { RedisClientProvider } from 'src/modules/pub-sub/providers/redis-client.provider'; +import { UserClient } from 'src/modules/pub-sub/model/user-client'; +import { RedisClient } from 'src/modules/pub-sub/model/redis-client'; + +const mockUserClient = new UserClient('socketId', mockSocket, 'databaseId'); +const mockUserClient2 = new UserClient('socketId2', mockSocket, 'databaseId'); +const getRedisClientFn = jest.fn(); +const mockRedisClient = new RedisClient('databaseId', getRedisClientFn); + +describe('UserSessionProvider', () => { + let service: UserSessionProvider; + let redisClientProvider: MockType; + + beforeEach(async () => { + jest.resetAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserSessionProvider, + { + provide: RedisClientProvider, + useFactory: () => ({ + createClient: jest.fn(), + }), + }, + + ], + }).compile(); + + service = await module.get(UserSessionProvider); + redisClientProvider = await module.get(RedisClientProvider); + + redisClientProvider.createClient.mockReturnValue(mockRedisClient); + }); + + describe('getOrCreateUserSession', () => { + it('should create new UserSession and store it. Ignore the same session', async () => { + expect(service['sessions'].size).toEqual(0); + const userSession = await service.getOrCreateUserSession(mockUserClient); + expect(service['sessions'].size).toEqual(1); + expect(service.getUserSession(userSession.getId())).toEqual(userSession); + await service.getOrCreateUserSession(mockUserClient); + expect(service['sessions'].size).toEqual(1); + expect(service.getUserSession(userSession.getId())).toEqual(userSession); + }); + }); + describe('removeUserSession', () => { + it('should remove UserSession', async () => { + expect(service['sessions'].size).toEqual(0); + await service.getOrCreateUserSession(mockUserClient); + await service.getOrCreateUserSession(mockUserClient2); + expect(service['sessions'].size).toEqual(2); + await service.removeUserSession(mockUserClient.getId()); + expect(service['sessions'].size).toEqual(1); + await service.removeUserSession(mockUserClient.getId()); + expect(service['sessions'].size).toEqual(1); + await service.removeUserSession(mockUserClient2.getId()); + expect(service['sessions'].size).toEqual(0); + }); + }); +}); diff --git a/redisinsight/api/src/modules/pub-sub/providers/user-session.provider.ts b/redisinsight/api/src/modules/pub-sub/providers/user-session.provider.ts new file mode 100644 index 0000000000..8abb03ad76 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/providers/user-session.provider.ts @@ -0,0 +1,49 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { UserSession } from 'src/modules/pub-sub/model/user-session'; +import { UserClient } from 'src/modules/pub-sub/model/user-client'; +import { RedisClientProvider } from 'src/modules/pub-sub/providers/redis-client.provider'; + +@Injectable() +export class UserSessionProvider { + private readonly logger: Logger = new Logger('UserSessionProvider'); + + private sessions: Map = new Map(); + + constructor(private readonly redisClientProvider: RedisClientProvider) {} + + getOrCreateUserSession(userClient: UserClient) { + let session = this.getUserSession(userClient.getId()); + + if (!session) { + session = new UserSession( + userClient, + this.redisClientProvider.createClient(userClient.getDatabaseId()), + ); + this.sessions.set(session.getId(), session); + this.logger.debug(`New session was added ${this}`); + } + + return session; + } + + getUserSession(id: string): UserSession { + return this.sessions.get(id); + } + + removeUserSession(id: string) { + this.logger.debug(`Removing user session ${id}`); + + this.sessions.delete(id); + + this.logger.debug(`User session was removed ${this}`); + } + + toString() { + return `UserSessionProvider:${ + JSON.stringify({ + sessionsSize: this.sessions.size, + sessions: [...this.sessions.keys()], + }) + }`; + } +} diff --git a/redisinsight/api/src/modules/pub-sub/pub-sub.analytics.service.spec.ts b/redisinsight/api/src/modules/pub-sub/pub-sub.analytics.service.spec.ts new file mode 100644 index 0000000000..64fba7fe76 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/pub-sub.analytics.service.spec.ts @@ -0,0 +1,76 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { mockStandaloneDatabaseEntity } from 'src/__mocks__'; +import { TelemetryEvents } from 'src/constants'; +import { PubSubAnalyticsService } from './pub-sub.analytics.service'; + +const instanceId = mockStandaloneDatabaseEntity.id; + +const affected = 2; + +describe('PubSubAnalyticsService', () => { + let service: PubSubAnalyticsService; + let sendEventMethod: jest.SpyInstance; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventEmitter2, + PubSubAnalyticsService, + ], + }).compile(); + + service = module.get(PubSubAnalyticsService); + sendEventMethod = jest.spyOn( + service, + 'sendEvent', + ); + }); + + describe('sendMessagePublishedEvent', () => { + it('should emit sendMessagePublished event', () => { + service.sendMessagePublishedEvent( + instanceId, + affected, + ); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.PubSubMessagePublished, + { + databaseId: instanceId, + clients: affected, + }, + ); + }); + }); + + describe('sendChannelSubscribeEvent', () => { + it('should emit sendChannelSubscribe event', () => { + service.sendChannelSubscribeEvent( + instanceId, + ); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.PubSubChannelSubscribed, + { + databaseId: instanceId, + }, + ); + }); + }); + + describe('sendChannelUnsubscribeEvent', () => { + it('should emit sendChannelUnsubscribe event', () => { + service.sendChannelUnsubscribeEvent( + instanceId, + ); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.PubSubChannelUnsubscribed, + { + databaseId: instanceId, + }, + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/pub-sub/pub-sub.analytics.service.ts b/redisinsight/api/src/modules/pub-sub/pub-sub.analytics.service.ts new file mode 100644 index 0000000000..d7959861c4 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/pub-sub.analytics.service.ts @@ -0,0 +1,59 @@ +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'; +import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; +import { RedisError, ReplyError } from 'src/models'; + +export interface IExecResult { + response: any; + status: CommandExecutionStatus; + error?: RedisError | ReplyError | Error, +} + +@Injectable() +export class PubSubAnalyticsService extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } + + sendMessagePublishedEvent(databaseId: string, affected: number): void { + try { + this.sendEvent( + TelemetryEvents.PubSubMessagePublished, + { + databaseId, + clients: affected, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendChannelSubscribeEvent(databaseId: string): void { + try { + this.sendEvent( + TelemetryEvents.PubSubChannelSubscribed, + { + databaseId, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendChannelUnsubscribeEvent(databaseId: string): void { + try { + this.sendEvent( + TelemetryEvents.PubSubChannelUnsubscribed, + { + databaseId, + }, + ); + } catch (e) { + // continue regardless of error + } + } +} diff --git a/redisinsight/api/src/modules/pub-sub/pub-sub.controller.ts b/redisinsight/api/src/modules/pub-sub/pub-sub.controller.ts new file mode 100644 index 0000000000..8ab37fda43 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/pub-sub.controller.ts @@ -0,0 +1,39 @@ +import { + Body, + Controller, Param, Post, UsePipes, ValidationPipe +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiRedisInstanceOperation } from 'src/decorators/api-redis-instance-operation.decorator'; +import { PubSubService } from 'src/modules/pub-sub/pub-sub.service'; +import { AppTool } from 'src/models'; +import { PublishDto } from 'src/modules/pub-sub/dto/publish.dto'; +import { PublishResponse } from 'src/modules/pub-sub/dto/publish.response'; + +@ApiTags('Pub/Sub') +@Controller('pub-sub') +@UsePipes(new ValidationPipe()) +export class PubSubController { + constructor(private service: PubSubService) {} + + @Post('messages') + @ApiRedisInstanceOperation({ + description: 'Publish message to a channel', + statusCode: 201, + responses: [ + { + status: 201, + description: 'Returns number of clients message ws delivered', + type: PublishResponse, + }, + ], + }) + async publish( + @Param('dbInstance') instanceId: string, + @Body() dto: PublishDto, + ): Promise { + return this.service.publish({ + instanceId, + tool: AppTool.Common, + }, dto); + } +} diff --git a/redisinsight/api/src/modules/pub-sub/pub-sub.gateway.ts b/redisinsight/api/src/modules/pub-sub/pub-sub.gateway.ts new file mode 100644 index 0000000000..09511ea581 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/pub-sub.gateway.ts @@ -0,0 +1,52 @@ +import { Socket, Server } from 'socket.io'; +import { + OnGatewayConnection, + OnGatewayDisconnect, + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { + Body, Logger, UseFilters, UsePipes, ValidationPipe, +} from '@nestjs/common'; +import config from 'src/utils/config'; +import { PubSubService } from 'src/modules/pub-sub/pub-sub.service'; +import { Client } from 'src/modules/pub-sub/decorators/client.decorator'; +import { UserClient } from 'src/modules/pub-sub/model/user-client'; +import { SubscribeDto } from 'src/modules/pub-sub/dto'; +import { AckWsExceptionFilter } from 'src/modules/pub-sub/filters/ack-ws-exception.filter'; +import { PubSubClientEvents } from './constants'; + +const SOCKETS_CONFIG = config.get('sockets'); + +@UsePipes(new ValidationPipe()) +@UseFilters(AckWsExceptionFilter) +@WebSocketGateway({ namespace: 'pub-sub', cors: SOCKETS_CONFIG.cors, serveClient: SOCKETS_CONFIG.serveClient }) +export class PubSubGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() wss: Server; + + private logger: Logger = new Logger('PubSubGateway'); + + constructor(private service: PubSubService) {} + + @SubscribeMessage(PubSubClientEvents.Subscribe) + async subscribe(@Client() client: UserClient, @Body() dto: SubscribeDto): Promise { + await this.service.subscribe(client, dto); + return { status: 'ok' }; + } + + @SubscribeMessage(PubSubClientEvents.Unsubscribe) + async unsubscribe(@Client() client: UserClient, @Body() dto: SubscribeDto): Promise { + await this.service.unsubscribe(client, dto); + return { status: 'ok' }; + } + + async handleConnection(client: Socket): Promise { + this.logger.log(`Client connected: ${client.id}`); + } + + async handleDisconnect(client: Socket): Promise { + await this.service.handleDisconnect(client.id); + this.logger.log(`Client disconnected: ${client.id}`); + } +} diff --git a/redisinsight/api/src/modules/pub-sub/pub-sub.module.ts b/redisinsight/api/src/modules/pub-sub/pub-sub.module.ts new file mode 100644 index 0000000000..06839aea06 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/pub-sub.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/modules/shared/shared.module'; +import { PubSubGateway } from 'src/modules/pub-sub/pub-sub.gateway'; +import { PubSubService } from 'src/modules/pub-sub/pub-sub.service'; +import { UserSessionProvider } from 'src/modules/pub-sub/providers/user-session.provider'; +import { SubscriptionProvider } from 'src/modules/pub-sub/providers/subscription.provider'; +import { RedisClientProvider } from 'src/modules/pub-sub/providers/redis-client.provider'; +import { PubSubController } from 'src/modules/pub-sub/pub-sub.controller'; +import { PubSubAnalyticsService } from 'src/modules/pub-sub/pub-sub.analytics.service'; + +@Module({ + imports: [SharedModule], + providers: [ + PubSubGateway, + PubSubService, + PubSubAnalyticsService, + UserSessionProvider, + SubscriptionProvider, + RedisClientProvider, + ], + controllers: [PubSubController], +}) +export class PubSubModule {} diff --git a/redisinsight/api/src/modules/pub-sub/pub-sub.service.spec.ts b/redisinsight/api/src/modules/pub-sub/pub-sub.service.spec.ts new file mode 100644 index 0000000000..f3e3be8bf8 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/pub-sub.service.spec.ts @@ -0,0 +1,233 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import * as Redis from 'ioredis'; +import { + // mockLogFile, + // mockRedisShardObserver, + mockSocket, + mockStandaloneDatabaseEntity, + MockType, + mockPubSubAnalyticsService +} from 'src/__mocks__'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +// import { RedisObserverProvider } from 'src/modules/profiler/providers/redis-observer.provider'; +import { IFindRedisClientInstanceByOptions, RedisService } from 'src/modules/core/services/redis/redis.service'; +import { mockRedisClientInstance } from 'src/modules/shared/services/base/redis-consumer.abstract.service.spec'; +// import { RedisObserverStatus } from 'src/modules/profiler/constants'; +import { PubSubService } from 'src/modules/pub-sub/pub-sub.service'; +import { UserSessionProvider } from 'src/modules/pub-sub/providers/user-session.provider'; +import { SubscriptionProvider } from 'src/modules/pub-sub/providers/subscription.provider'; +import { UserClient } from 'src/modules/pub-sub/model/user-client'; +import { SubscriptionType } from 'src/modules/pub-sub/constants'; +// import { RedisClientProvider } from 'src/modules/pub-sub/providers/redis-client.provider'; +import { UserSession } from 'src/modules/pub-sub/model/user-session'; +import { RedisClient } from 'src/modules/pub-sub/model/redis-client'; +import { PubSubAnalyticsService } from 'src/modules/pub-sub/pub-sub.analytics.service'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; + +const nodeClient = Object.create(Redis.prototype); +nodeClient.subscribe = jest.fn(); +nodeClient.psubscribe = jest.fn(); +nodeClient.unsubscribe = jest.fn(); +nodeClient.punsubscribe = jest.fn(); +nodeClient.status = 'ready'; +nodeClient.disconnect = jest.fn(); +nodeClient.publish = jest.fn(); + +const mockUserClient = new UserClient('socketId', mockSocket, 'databaseId'); + +const mockSubscriptionDto = { + channel: 'channel-a', + type: SubscriptionType.Subscribe, +}; + +const mockPSubscriptionDto = { + channel: 'channel-a', + type: SubscriptionType.PSubscribe, +}; + +const getRedisClientFn = jest.fn(); +const mockRedisClient = new RedisClient('databaseId', getRedisClientFn); +const mockUserSession = new UserSession(mockUserClient, mockRedisClient); + +const mockSubscribe = jest.fn(); +const mockUnsubscribe = jest.fn(); +mockUserSession['subscribe'] = mockSubscribe; +mockUserSession['unsubscribe'] = mockUnsubscribe; +mockUserSession['destroy'] = jest.fn(); + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const mockPublishDto = { + message: 'message-a', + channel: 'channel-a', +}; + +describe('PubSubService', () => { + let service: PubSubService; + let sessionProvider: MockType; + let redisService: MockType; + let databaseService: MockType; + + beforeEach(async () => { + jest.resetAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PubSubService, + UserSessionProvider, + SubscriptionProvider, + { + provide: UserSessionProvider, + useFactory: () => ({ + getOrCreateUserSession: jest.fn(), + getUserSession: jest.fn(), + removeUserSession: jest.fn(), + }), + }, + { + provide: PubSubAnalyticsService, + useFactory: mockPubSubAnalyticsService, + }, + { + provide: RedisService, + useFactory: () => ({ + getClientInstance: jest.fn(), + isClientConnected: jest.fn(), + }), + }, + { + provide: InstancesBusinessService, + useFactory: () => ({ + connectToInstance: jest.fn(), + getOneById: jest.fn(), + }), + }, + ], + }).compile(); + + service = await module.get(PubSubService); + redisService = await module.get(RedisService); + databaseService = await module.get(InstancesBusinessService); + sessionProvider = await module.get(UserSessionProvider); + + getRedisClientFn.mockResolvedValue(nodeClient); + sessionProvider.getOrCreateUserSession.mockReturnValue(mockUserSession); + sessionProvider.getUserSession.mockReturnValue(mockUserSession); + sessionProvider.removeUserSession.mockReturnValue(undefined); + mockSubscribe.mockResolvedValue('OK'); + mockUnsubscribe.mockResolvedValue('OK'); + redisService.getClientInstance.mockReturnValue({ ...mockRedisClientInstance, client: nodeClient }); + redisService.isClientConnected.mockReturnValue(true); + databaseService.connectToInstance.mockResolvedValue(nodeClient); + nodeClient.publish.mockResolvedValue(2); + }); + + describe('subscribe', () => { + it('should subscribe to a single channel', async () => { + await service.subscribe(mockUserClient, { subscriptions: [mockSubscriptionDto] }); + expect(mockUserSession.subscribe).toHaveBeenCalledTimes(1); + }); + it('should subscribe to a multiple channels', async () => { + await service.subscribe(mockUserClient, { subscriptions: [mockSubscriptionDto, mockPSubscriptionDto] }); + expect(mockUserSession.subscribe).toHaveBeenCalledTimes(2); + }); + it('should handle HTTP error', async () => { + try { + mockSubscribe.mockRejectedValueOnce(new NotFoundException('Not Found')); + await service.subscribe(mockUserClient, { subscriptions: [mockSubscriptionDto] }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + } + }); + it('should handle acl error', async () => { + try { + mockSubscribe.mockRejectedValueOnce(new Error('NOPERM')); + await service.subscribe(mockUserClient, { subscriptions: [mockSubscriptionDto] }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('unsubscribe', () => { + it('should unsubscribe from a single channel', async () => { + await service.unsubscribe(mockUserClient, { subscriptions: [mockSubscriptionDto] }); + expect(mockUserSession.unsubscribe).toHaveBeenCalledTimes(1); + }); + it('should unsubscribe from multiple channels', async () => { + await service.unsubscribe(mockUserClient, { subscriptions: [mockSubscriptionDto, mockPSubscriptionDto] }); + expect(mockUserSession.unsubscribe).toHaveBeenCalledTimes(2); + }); + it('should handle HTTP error', async () => { + try { + mockUnsubscribe.mockRejectedValueOnce(new NotFoundException('Not Found')); + await service.unsubscribe(mockUserClient, { subscriptions: [mockSubscriptionDto] }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + } + }); + it('should handle acl error', async () => { + try { + mockUnsubscribe.mockRejectedValueOnce(new Error('NOPERM')); + await service.unsubscribe(mockUserClient, { subscriptions: [mockSubscriptionDto] }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('publish', () => { + it('should publish using existing client', async () => { + const res = await service.publish(mockClientOptions, mockPublishDto); + expect(res).toEqual({ affected: 2 }); + }); + it('should publish using new client', async () => { + redisService.isClientConnected.mockReturnValueOnce(false); + const res = await service.publish(mockClientOptions, mockPublishDto); + expect(res).toEqual({ affected: 2 }); + }); + it('should handle HTTP error', async () => { + try { + redisService.getClientInstance.mockImplementation(() => { + throw new NotFoundException('Not Found'); + }); + + await service.publish(mockClientOptions, mockPublishDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + } + }); + it('should handle acl error', async () => { + try { + redisService.getClientInstance.mockImplementation(() => { + throw new Error('NOPERM'); + }); + + await service.publish(mockClientOptions, mockPublishDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('handleDisconnect', () => { + it('should not do anything if no sessions', async () => { + sessionProvider.getUserSession.mockReturnValueOnce(undefined); + await service.handleDisconnect(mockUserClient.getId()); + expect(sessionProvider.removeUserSession).toHaveBeenCalledTimes(0); + }); + it('should call session.destroy and remove session', async () => { + await service.handleDisconnect(mockUserClient.getId()); + expect(sessionProvider.removeUserSession).toHaveBeenCalledTimes(1); + expect(mockUserSession.destroy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/redisinsight/api/src/modules/pub-sub/pub-sub.service.ts b/redisinsight/api/src/modules/pub-sub/pub-sub.service.ts new file mode 100644 index 0000000000..7dcb7e83d5 --- /dev/null +++ b/redisinsight/api/src/modules/pub-sub/pub-sub.service.ts @@ -0,0 +1,142 @@ +import { HttpException, Injectable, Logger } from '@nestjs/common'; +import { UserSessionProvider } from 'src/modules/pub-sub/providers/user-session.provider'; +import { UserClient } from 'src/modules/pub-sub/model/user-client'; +import { SubscribeDto } from 'src/modules/pub-sub/dto'; +import { SubscriptionProvider } from 'src/modules/pub-sub/providers/subscription.provider'; +import { IFindRedisClientInstanceByOptions, RedisService } from 'src/modules/core/services/redis/redis.service'; +import { PublishResponse } from 'src/modules/pub-sub/dto/publish.response'; +import { PublishDto } from 'src/modules/pub-sub/dto/publish.dto'; +import { PubSubAnalyticsService } from 'src/modules/pub-sub/pub-sub.analytics.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { catchAclError } from 'src/utils'; + +@Injectable() +export class PubSubService { + private logger: Logger = new Logger('PubSubService'); + + constructor( + private readonly sessionProvider: UserSessionProvider, + private readonly subscriptionProvider: SubscriptionProvider, + private redisService: RedisService, + private instancesBusinessService: InstancesBusinessService, + private analyticsService: PubSubAnalyticsService, + ) {} + + /** + * Subscribe to multiple channels + * @param userClient + * @param dto + */ + async subscribe(userClient: UserClient, dto: SubscribeDto) { + try { + this.logger.log('Subscribing to channels(s)'); + + const session = await this.sessionProvider.getOrCreateUserSession(userClient); + await Promise.all(dto.subscriptions.map((subDto) => session.subscribe( + this.subscriptionProvider.createSubscription(userClient, subDto), + ))); + this.analyticsService.sendChannelSubscribeEvent(userClient.getDatabaseId()); + } catch (e) { + this.logger.error('Unable to create subscriptions', e); + + if (e instanceof HttpException) { + throw e; + } + + throw catchAclError(e); + } + } + + /** + * Unsubscribe from multiple channels + * @param userClient + * @param dto + */ + async unsubscribe(userClient: UserClient, dto: SubscribeDto) { + try { + this.logger.log('Unsubscribing from channels(s)'); + + const session = await this.sessionProvider.getOrCreateUserSession(userClient); + await Promise.all(dto.subscriptions.map((subDto) => session.unsubscribe( + this.subscriptionProvider.createSubscription(userClient, subDto), + ))); + this.analyticsService.sendChannelUnsubscribeEvent(userClient.getDatabaseId()); + } catch (e) { + this.logger.error('Unable to unsubscribe', e); + + if (e instanceof HttpException) { + throw e; + } + + throw catchAclError(e); + } + } + + /** + * Publish a message to a particular channel + * @param clientOptions + * @param dto + */ + async publish( + clientOptions: IFindRedisClientInstanceByOptions, + dto: PublishDto, + ): Promise { + try { + this.logger.log('Publishing message.'); + + const client = await this.getClient(clientOptions); + const affected = await client.publish(dto.channel, dto.message); + + this.analyticsService.sendMessagePublishedEvent(clientOptions.instanceId, affected); + + return { + affected, + }; + } catch (e) { + this.logger.error('Unable to publish a message', e); + + if (e instanceof HttpException) { + throw e; + } + + throw catchAclError(e); + } + } + + /** + * 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, + ); + } + + /** + * Handle Socket disconnection event + * Basically destroy the UserSession to remove Redis connection + * @param id + */ + async handleDisconnect(id: string) { + this.logger.log(`Handle disconnect event: ${id}`); + const session = this.sessionProvider.getUserSession(id); + + if (session) { + session.destroy(); + this.sessionProvider.removeUserSession(id); + } + } +} diff --git a/redisinsight/api/src/modules/shared/services/instances-business/auto-discovery.service.ts b/redisinsight/api/src/modules/shared/services/instances-business/auto-discovery.service.ts index 96ea09d599..950d231f31 100644 --- a/redisinsight/api/src/modules/shared/services/instances-business/auto-discovery.service.ts +++ b/redisinsight/api/src/modules/shared/services/instances-business/auto-discovery.service.ts @@ -5,7 +5,7 @@ import { DatabasesProvider } from 'src/modules/shared/services/instances-busines import { RedisService } from 'src/modules/core/services/redis/redis.service'; import { AppTool } from 'src/models'; import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; -import { getAvailableEndpoints, getRunningProcesses, getTCP4Endpoints } from 'src/utils/auto-discovery-helper'; +import { getAvailableEndpoints, getRunningProcesses, getTCPEndpoints } from 'src/utils/auto-discovery-helper'; import { convertRedisInfoReplyToObject } from 'src/utils'; import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; import config from 'src/utils/config'; @@ -56,7 +56,7 @@ export class AutoDiscoveryService implements OnModuleInit { * @private */ private async discoverDatabases() { - const endpoints = await getAvailableEndpoints(getTCP4Endpoints(await getRunningProcesses())); + const endpoints = await getAvailableEndpoints(getTCPEndpoints(await getRunningProcesses())); // Add redis databases or resolve after 1s to not block app startup for a long time await Promise.race([ diff --git a/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.spec.ts b/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.spec.ts index b793aa8d49..c375ec7dc7 100644 --- a/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.spec.ts +++ b/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.spec.ts @@ -26,6 +26,7 @@ const mockDatabaseInstanceDto: DatabaseInstanceResponse = { verifyServerCert: true, caCertId: mockCaCertEntity.id, clientCertPairId: mockClientCertEntity.id, + servername: mockStandaloneDatabaseEntity.tlsServername, }, modules: [], }; @@ -100,7 +101,7 @@ describe('InstancesAnalytics', () => { }); describe('sendInstanceAddedEvent', () => { - it('should emit event with enabled tls', () => { + it('should emit event with enabled tls and sni', () => { const instance = mockDatabaseInstanceDto; service.sendInstanceAddedEvent(instance, mockRedisGeneralInfo); @@ -113,6 +114,7 @@ describe('InstancesAnalytics', () => { useTLS: 'enabled', verifyTLSCertificate: 'enabled', useTLSAuthClients: 'enabled', + useSNI: 'enabled', version: mockRedisGeneralInfo.version, numberOfKeys: mockRedisGeneralInfo.totalKeys, numberOfKeysRange: '0 - 500 000', @@ -123,7 +125,7 @@ describe('InstancesAnalytics', () => { }, ); }); - it('should emit event with disabled tls', () => { + it('should emit event with disabled tls and sni', () => { const instance = { ...mockDatabaseInstanceDto, tls: undefined, @@ -139,6 +141,7 @@ describe('InstancesAnalytics', () => { useTLS: 'disabled', verifyTLSCertificate: 'disabled', useTLSAuthClients: 'disabled', + useSNI: 'disabled', version: mockRedisGeneralInfo.version, numberOfKeys: mockRedisGeneralInfo.totalKeys, numberOfKeysRange: '0 - 500 000', @@ -167,6 +170,7 @@ describe('InstancesAnalytics', () => { useTLS: 'enabled', verifyTLSCertificate: 'enabled', useTLSAuthClients: 'enabled', + useSNI: 'enabled', version: mockRedisGeneralInfo.version, numberOfKeys: undefined, numberOfKeysRange: undefined, @@ -275,6 +279,7 @@ describe('InstancesAnalytics', () => { TelemetryEvents.RedisInstanceDeleted, { databaseId: mockDatabaseInstanceDto.id, + provider: mockDatabaseInstanceDto.provider, }, ); }); diff --git a/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.ts b/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.ts index 164c4b22e7..48c421c58e 100644 --- a/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.ts +++ b/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.ts @@ -49,6 +49,9 @@ export class InstancesAnalyticsService extends TelemetryBaseService { useTLSAuthClients: instance?.tls?.clientCertPairId ? 'enabled' : 'disabled', + useSNI: instance?.tls?.servername + ? 'enabled' + : 'disabled', version: additionalInfo.version, numberOfKeys: additionalInfo.totalKeys, numberOfKeysRange: getRangeForNumber(additionalInfo.totalKeys, TOTAL_KEYS_BREAKPOINTS), @@ -109,6 +112,7 @@ export class InstancesAnalyticsService extends TelemetryBaseService { TelemetryEvents.RedisInstanceDeleted, { databaseId: instance.id, + provider: instance.provider, }, ); } diff --git a/redisinsight/api/src/modules/shared/services/instances-business/instances-business.service.spec.ts b/redisinsight/api/src/modules/shared/services/instances-business/instances-business.service.spec.ts index 86ea13d699..2a66081f29 100644 --- a/redisinsight/api/src/modules/shared/services/instances-business/instances-business.service.spec.ts +++ b/redisinsight/api/src/modules/shared/services/instances-business/instances-business.service.spec.ts @@ -71,6 +71,7 @@ const addDatabaseDto: AddDatabaseInstanceDto = { verifyServerCert: true, caCertId: mockCaCertEntity.id, clientCertPairId: mockClientCertEntity.id, + servername: mockStandaloneDatabaseEntity.tlsServername, }, }; diff --git a/redisinsight/api/src/modules/shared/services/instances-business/overview.service.spec.ts b/redisinsight/api/src/modules/shared/services/instances-business/overview.service.spec.ts index 5f6afb715f..bdacd4de7f 100644 --- a/redisinsight/api/src/modules/shared/services/instances-business/overview.service.spec.ts +++ b/redisinsight/api/src/modules/shared/services/instances-business/overview.service.spec.ts @@ -41,7 +41,7 @@ const mockClientsInfo = { const mockKeyspace = { db0: 'keys=1,expires=0,avg_ttl=0', db1: 'keys=0,expires=0,avg_ttl=0', - db2: 'keys=0,expires=0,avg_ttl=0', + db2: 'keys=1,expires=0,avg_ttl=0', }; const mockNodeInfo = { host: 'localhost', @@ -59,7 +59,10 @@ const databaseId = mockStandaloneDatabaseEntity.id; export const mockDatabaseOverview: DatabaseOverview = { version: mockServerInfo.redis_version, usedMemory: 1, - totalKeys: 1, + totalKeys: 2, + totalKeysPerDb: { + db0: 1, + }, connectedClients: 1, opsPerSecond: 1, networkInKbps: 1, @@ -94,6 +97,7 @@ describe('OverviewService', () => { version: '6.0.5', connectedClients: 1, totalKeys: 1, + totalKeysPerDb: undefined, usedMemory: 1000000, cpuUsagePercentage: undefined, opsPerSecond: undefined, @@ -101,6 +105,20 @@ describe('OverviewService', () => { networkOutKbps: undefined, }); }); + it('should return total 0 and empty total per db object', async () => { + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + keyspace: { + db0: 'keys=0,expires=0,avg_ttl=0', + }, + }); + + expect(await service.getOverview(databaseId, mockClient)).toEqual({ + ...mockDatabaseOverview, + totalKeys: 0, + totalKeysPerDb: undefined, + }); + }); it('check for cpu on second attempt', async () => { spyGetNodeInfo.mockResolvedValueOnce(mockNodeInfo); spyGetNodeInfo.mockResolvedValueOnce({ @@ -205,7 +223,10 @@ describe('OverviewService', () => { expect(await service.getOverview(databaseId, mockCluster)).toEqual({ ...mockDatabaseOverview, connectedClients: 1, - totalKeys: 3, + totalKeys: 6, + totalKeysPerDb: { + db0: 3, + }, usedMemory: 3, networkInKbps: 6, networkOutKbps: 6, @@ -256,7 +277,10 @@ describe('OverviewService', () => { expect(await service.getOverview(databaseId, mockCluster)).toEqual({ ...mockDatabaseOverview, connectedClients: 1, - totalKeys: 3, + totalKeys: 6, + totalKeysPerDb: { + db0: 3, + }, usedMemory: 3, networkInKbps: 6, networkOutKbps: 6, diff --git a/redisinsight/api/src/modules/shared/services/instances-business/overview.service.ts b/redisinsight/api/src/modules/shared/services/instances-business/overview.service.ts index be97eba01c..3c3e7f2433 100644 --- a/redisinsight/api/src/modules/shared/services/instances-business/overview.service.ts +++ b/redisinsight/api/src/modules/shared/services/instances-business/overview.service.ts @@ -28,15 +28,21 @@ export class OverviewService { client: IORedis.Redis | IORedis.Cluster, ): Promise { let nodesInfo = []; + let currentDbIndex = 0; + if (client instanceof IORedis.Cluster) { + currentDbIndex = get(client, ['options', 'db'], 0); nodesInfo = await this.getNodesInfo(client); } else { + currentDbIndex = get(client, ['options', 'db'], 0); nodesInfo = [await this.getNodeInfo(client)]; } + const [totalKeys, totalKeysPerDb] = this.calculateTotalKeys(nodesInfo, currentDbIndex); return { version: this.getVersion(nodesInfo), - totalKeys: this.calculateTotalKeys(nodesInfo), + totalKeys, + totalKeysPerDb, usedMemory: this.calculateUsedMemory(nodesInfo), connectedClients: this.calculateConnectedClients(nodesInfo), opsPerSecond: this.calculateOpsPerSec(nodesInfo), @@ -190,11 +196,12 @@ export class OverviewService { * Sum of keys for primary shards * In case when shard has multiple logical databases shard total keys = sum of all dbs keys * @param nodes + * @param index * @private */ - private calculateTotalKeys(nodes = []): number { + private calculateTotalKeys(nodes = [], index: number): [number, Record] { if (!this.isMetricsAvailable(nodes, 'keyspace', [undefined])) { - return undefined; + return [undefined, undefined]; } try { @@ -202,17 +209,28 @@ export class OverviewService { get(node, 'replication.role'), )); - return sumBy(masterNodes, (node) => sum( + const totalKeysPerDb = {}; + + masterNodes.forEach((node) => { map( get(node, 'keyspace', {}), - (dbKeys): number => { + (dbKeys, dbNumber): void => { const { keys } = convertBulkStringsToObject(dbKeys, ',', '='); - return parseInt(keys, 10); + + if (!totalKeysPerDb[dbNumber]) { + totalKeysPerDb[dbNumber] = 0; + } + + totalKeysPerDb[dbNumber] += parseInt(keys, 10); }, - ), - )); + ); + }); + + const totalKeys = totalKeysPerDb ? sum(Object.values(totalKeysPerDb)) : undefined; + const dbIndexKeys = totalKeysPerDb[`db${index}`] || 0; + return [totalKeys, dbIndexKeys === totalKeys ? undefined : { [`db${index}`]: dbIndexKeys }]; } catch (e) { - return null; + return [null, null]; } } diff --git a/redisinsight/api/src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service.ts b/redisinsight/api/src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service.ts index ac1088d7a5..a0139749d3 100644 --- a/redisinsight/api/src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service.ts +++ b/redisinsight/api/src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service.ts @@ -126,7 +126,7 @@ export class RedisEnterpriseBusinessService { public getDatabaseExternalEndpoint( database: IRedisEnterpriseDatabase, ): IRedisEnterpriseEndpoint { - return database.endpoints.filter((endpoint: { addr_type: string }) => endpoint.addr_type === 'external')[0]; + return database.endpoints?.filter((endpoint: { addr_type: string }) => endpoint.addr_type === 'external')[0]; } private getDatabasePersistencePolicy( diff --git a/redisinsight/api/src/modules/workbench/utils/getUnsupportedCommands.spec.ts b/redisinsight/api/src/modules/workbench/utils/getUnsupportedCommands.spec.ts index 8839fb1ea3..69c2a2d35e 100644 --- a/redisinsight/api/src/modules/workbench/utils/getUnsupportedCommands.spec.ts +++ b/redisinsight/api/src/modules/workbench/utils/getUnsupportedCommands.spec.ts @@ -2,7 +2,7 @@ import { getUnsupportedCommands } from './getUnsupportedCommands'; describe('workbench unsupported commands', () => { it('should return correct list', () => { - const expectedResult = ['monitor', 'subscribe', 'psubscribe', 'sync', 'psync', 'script debug', 'select']; + const expectedResult = ['monitor', 'subscribe', 'psubscribe', 'ssubscribe', 'sync', 'psync', 'script debug', 'select']; expect(getUnsupportedCommands()).toEqual(expectedResult); }); diff --git a/redisinsight/api/src/modules/workbench/utils/getUnsupportedCommands.ts b/redisinsight/api/src/modules/workbench/utils/getUnsupportedCommands.ts index bdd082a51a..a2c9720ed2 100644 --- a/redisinsight/api/src/modules/workbench/utils/getUnsupportedCommands.ts +++ b/redisinsight/api/src/modules/workbench/utils/getUnsupportedCommands.ts @@ -6,6 +6,7 @@ export enum WorkbenchToolUnsupportedCommands { Monitor = 'monitor', Subscribe = 'subscribe', PSubscribe = 'psubscribe', + SSubscribe = 'ssubscribe', Sync = 'sync', PSync = 'psync', ScriptDebug = 'script debug', diff --git a/redisinsight/api/src/utils/auto-discovery-helper.spec.ts b/redisinsight/api/src/utils/auto-discovery-helper.spec.ts index 5cd352a015..205c9f67f4 100644 --- a/redisinsight/api/src/utils/auto-discovery-helper.spec.ts +++ b/redisinsight/api/src/utils/auto-discovery-helper.spec.ts @@ -1,5 +1,5 @@ import { - getTCP4Endpoints, + getTCPEndpoints, } from 'src/utils/auto-discovery-helper'; const winNetstat = '' @@ -12,6 +12,7 @@ const winNetstat = '' + 'TCP [::]:445 [::]:0 LISTENING 4\n' + 'TCP [::]:808 [::]:0 LISTENING 6084\n' + 'TCP [::]:2701 [::]:0 LISTENING 6056\n' + + 'TCP [::]:5000 [::]:0 LISTENING 6056\n' + 'TCP *:* LISTENING 6056'; const linuxNetstat = '' @@ -39,7 +40,7 @@ const macNetstat = '' + 'tcp6 0 0 ::1.52167 ::1.5002 ESTABLISHED 406172 146808 31200 0 0x0102 0x00000008\n'; /* eslint-enable max-len */ -const getTCP4EndpointsTests = [ +const getTCPEndpointsTests = [ { name: 'win output', input: winNetstat.split('\n'), @@ -47,6 +48,10 @@ const getTCP4EndpointsTests = [ { host: 'localhost', port: 5000 }, { host: 'localhost', port: 6379 }, { host: 'localhost', port: 6380 }, + { host: 'localhost', port: 135 }, + { host: 'localhost', port: 445 }, + { host: 'localhost', port: 808 }, + { host: 'localhost', port: 2701 }, ], }, { @@ -56,6 +61,12 @@ const getTCP4EndpointsTests = [ { host: 'localhost', port: 5000 }, { host: 'localhost', port: 6379 }, { host: 'localhost', port: 6380 }, + { host: 'localhost', port: 28100 }, + { host: 'localhost', port: 8100 }, + { host: 'localhost', port: 8101 }, + { host: 'localhost', port: 8102 }, + { host: 'localhost', port: 8103 }, + { host: 'localhost', port: 8200 }, ], }, { @@ -65,14 +76,16 @@ const getTCP4EndpointsTests = [ { host: 'localhost', port: 5000 }, { host: 'localhost', port: 6379 }, { host: 'localhost', port: 6380 }, + { host: 'localhost', port: 5002 }, + { host: 'localhost', port: 52167 }, ], }, ]; describe('getTCP4Endpoints', () => { - getTCP4EndpointsTests.forEach((test) => { + getTCPEndpointsTests.forEach((test) => { it(`Should return endpoints to test ${test.name}`, async () => { - const result = getTCP4Endpoints(test.input); + const result = getTCPEndpoints(test.input); expect(result).toEqual(test.output); }); diff --git a/redisinsight/api/src/utils/auto-discovery-helper.ts b/redisinsight/api/src/utils/auto-discovery-helper.ts index 840ebdd713..2d00748a3e 100644 --- a/redisinsight/api/src/utils/auto-discovery-helper.ts +++ b/redisinsight/api/src/utils/auto-discovery-helper.ts @@ -52,17 +52,17 @@ export const getRunningProcesses = async (): Promise => new Promise((r * Return list of unique endpoints (host is hardcoded) to test * @param processes */ -export const getTCP4Endpoints = (processes: string[]): Endpoint[] => { - const regExp = /(\d+\.\d+\.\d+\.\d+|\*)[:.](\d+)/; +export const getTCPEndpoints = (processes: string[]): Endpoint[] => { + const regExp = /\s((\d+\.\d+\.\d+\.\d+|\*)[:.]|([0-9a-fA-F\][]{0,4}[.:]){1,8})(\d+)\s/; const endpoints = new Map(); processes.forEach((line) => { const match = line.match(regExp); if (match) { - endpoints.set(match[2], { + endpoints.set(match[4], { host: 'localhost', - port: parseInt(match[2], 10), + port: parseInt(match[4], 10), }); } }); diff --git a/redisinsight/api/test/api/info/GET-info-cli-unsupported-commands.test.ts b/redisinsight/api/test/api/info/GET-info-cli-unsupported-commands.test.ts index bbdd4d97bf..5e9baceb92 100644 --- a/redisinsight/api/test/api/info/GET-info-cli-unsupported-commands.test.ts +++ b/redisinsight/api/test/api/info/GET-info-cli-unsupported-commands.test.ts @@ -27,7 +27,7 @@ describe('GET /info/cli-unsupported-commands', () => { name: 'Should return array with unsupported commands for CLI tool', statusCode: 200, responseSchema, - responseBody: ['monitor', 'subscribe', 'psubscribe', 'sync', 'psync', 'script debug'], + responseBody: ['monitor', 'subscribe', 'psubscribe', 'ssubscribe', 'sync', 'psync', 'script debug'], }, ].map(mainCheckFn); }); diff --git a/redisinsight/api/test/api/info/GET-info.test.ts b/redisinsight/api/test/api/info/GET-info.test.ts index ab16f69986..af557dfdf6 100644 --- a/redisinsight/api/test/api/info/GET-info.test.ts +++ b/redisinsight/api/test/api/info/GET-info.test.ts @@ -17,6 +17,7 @@ const responseSchema = Joi.object().keys({ appVersion: Joi.string().required(), osPlatform: Joi.string().required(), buildType: Joi.string().valid('ELECTRON', 'DOCKER_ON_PREMISE', 'REDIS_STACK').required(), + appType: Joi.string().valid('ELECTRON', 'DOCKER', 'REDIS_STACK_WEB', 'UNKNOWN').required(), encryptionStrategies: Joi.array().items(Joi.string()), sessionId: Joi.number().required(), }).required(); diff --git a/redisinsight/api/test/api/instance/GET-instance-id-overview.test.ts b/redisinsight/api/test/api/instance/GET-instance-id-overview.test.ts index 5fd1d1d79f..b6019ce7b8 100644 --- a/redisinsight/api/test/api/instance/GET-instance-id-overview.test.ts +++ b/redisinsight/api/test/api/instance/GET-instance-id-overview.test.ts @@ -8,6 +8,7 @@ const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => const responseSchema = Joi.object().keys({ version: Joi.string().required(), totalKeys: Joi.number().integer().allow(null), + totalKeysPerDb: Joi.object().allow(null), usedMemory: Joi.number().integer().allow(null), connectedClients: Joi.number().allow(null), opsPerSecond: Joi.number().allow(null), @@ -68,6 +69,7 @@ describe('GET /instance/:instanceId/overview', () => { expect(body.version).to.eql(rte.env.version); expect(body.cpuUsagePercentage).to.eql(undefined) expect(body.totalKeys).to.not.eql(undefined) + expect(body.totalKeysPerDb).to.eql(undefined) expect(body.connectedClients).to.not.eql(undefined) expect(body.opsPerSecond).to.not.eql(undefined) expect(body.networkInKbps).to.not.eql(undefined) diff --git a/redisinsight/api/test/api/pub-sub/POST-instance-id-pub-sub-messages.test.ts b/redisinsight/api/test/api/pub-sub/POST-instance-id-pub-sub-messages.test.ts new file mode 100644 index 0000000000..5202b75af5 --- /dev/null +++ b/redisinsight/api/test/api/pub-sub/POST-instance-id-pub-sub-messages.test.ts @@ -0,0 +1,114 @@ +import { + 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}/pub-sub/messages`); + +const dataSchema = Joi.object({ + channel: Joi.string().allow('').required(), + message: Joi.string().allow('').required(), +}).strict(); + +const validInputData = { + channel: constants.TEST_PUB_SUB_CHANNEL_1, + message: constants.TEST_PUB_SUB_MESSAGE_1, +}; + +const responseSchema = Joi.object().keys({ + affected: Joi.number().integer().required().min(0), +}).required().strict(); + +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/pub-sub/messages', () => { + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should send message', + data: { + ...validInputData, + }, + responseSchema, + statusCode: 201, + }, + { + 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 () => await rte.data.generateKeys(true)); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should publish method', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + statusCode: 201, + data: { + ...validInputData, + }, + }, + { + name: 'Should throw error if no permissions for "publish" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -publish') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/stream/DELETE-instance-id-streams-consumer_groups-consumers.test.ts b/redisinsight/api/test/api/stream/DELETE-instance-id-streams-consumer_groups-consumers.test.ts new file mode 100644 index 0000000000..1eaa1a9916 --- /dev/null +++ b/redisinsight/api/test/api/stream/DELETE-instance-id-streams-consumer_groups-consumers.test.ts @@ -0,0 +1,232 @@ +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).delete(`/instance/${instanceId}/streams/consumer-groups/consumers`); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + groupName: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), + consumerNames: Joi.array().items(Joi.string().required().label('consumerNames').messages({ + 'any.required': '{#label} should not be empty', + })).min(1).required().messages({ + 'array.sparse': 'each value in consumerNames must be a string', + }), +}).strict(); + +const validInputData = { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerNames: [constants.TEST_STREAM_GROUP_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('DELETE /instance/:instanceId/streams/consumer-groups/consumers', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + beforeEach(async () => { + await rte.data.sendCommand('xreadgroup', [ + 'GROUP', + constants.TEST_STREAM_GROUP_1, + constants.TEST_STREAM_CONSUMER_1, + 'STREAMS', + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_ID_1, + ]); + await rte.data.sendCommand('xreadgroup', [ + 'GROUP', + constants.TEST_STREAM_GROUP_1, + constants.TEST_STREAM_CONSUMER_2, + 'STREAMS', + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_ID_2, + ]); + }); + + [ + { + name: 'Should remove single consumer', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerNames: [constants.TEST_STREAM_CONSUMER_1], + }, + before: async () => { + const consumers = await rte.data.sendCommand('xinfo', ['consumers', constants.TEST_STREAM_KEY_1, constants.TEST_STREAM_GROUP_1]); + expect(consumers.length).to.eq(2); + }, + after: async () => { + const consumers = await rte.data.sendCommand('xinfo', ['consumers', constants.TEST_STREAM_KEY_1, constants.TEST_STREAM_GROUP_1]); + expect(consumers.length).to.eq(1); + }, + }, + { + name: 'Should remove multiple consumers', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerNames: [constants.TEST_STREAM_CONSUMER_1, constants.TEST_STREAM_CONSUMER_2], + }, + before: async () => { + const consumers = await rte.data.sendCommand('xinfo', ['consumers', constants.TEST_STREAM_KEY_1, constants.TEST_STREAM_GROUP_1]); + expect(consumers.length).to.eq(2); + }, + after: async () => { + const consumers = await rte.data.sendCommand('xinfo', ['consumers', constants.TEST_STREAM_KEY_1, constants.TEST_STREAM_GROUP_1]); + expect(consumers.length).to.eq(0); + }, + }, + { + name: 'Should remove single consumers and skip not existing consumers', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerNames: [constants.TEST_STREAM_CONSUMER_1, constants.getRandomString(), constants.getRandomString()], + }, + before: async () => { + const consumers = await rte.data.sendCommand('xinfo', ['consumers', constants.TEST_STREAM_KEY_1, constants.TEST_STREAM_GROUP_1]); + console.log('_c', consumers) + expect(consumers.length).to.eq(2); + }, + after: async () => { + const consumers = await rte.data.sendCommand('xinfo', ['consumers', constants.TEST_STREAM_KEY_1, constants.TEST_STREAM_GROUP_1]); + expect(consumers.length).to.eq(1); + }, + }, + { + name: 'Should return BadRequest error if key has another type', + data: { + ...validInputData, + keyName: constants.TEST_STRING_KEY_1, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should return NotFound error if group does not exists', + data: { + ...validInputData, + groupName: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Consumer Group with such name was not found.', + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + ...validInputData, + 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: { + ...validInputData, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + + before(async () => await rte.data.generateKeys(true)); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create consumer group', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "xgroup)" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xgroup') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/stream/DELETE-instance-id-streams-consumer_groups.test.ts b/redisinsight/api/test/api/stream/DELETE-instance-id-streams-consumer_groups.test.ts new file mode 100644 index 0000000000..5c82e61fee --- /dev/null +++ b/redisinsight/api/test/api/stream/DELETE-instance-id-streams-consumer_groups.test.ts @@ -0,0 +1,191 @@ +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).delete(`/instance/${instanceId}/streams/consumer-groups`); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + consumerGroups: Joi.array().items(Joi.string().label('consumerGroups').required().messages({ + 'any.required': '{#label} should not be empty', + })).required().min(1).messages({ + 'array.sparse': 'each value in consumerGroups must be a string', + }), +}).strict(); + +const validInputData = { + keyName: constants.TEST_STREAM_KEY_1, + consumerGroups: [constants.TEST_STREAM_GROUP_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('DELETE /instance/:instanceId/streams/consumer-groups', () => { + beforeEach(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should delete consumer group', + data: { + keyName: constants.TEST_STREAM_KEY_1, + consumerGroups: [constants.TEST_STREAM_GROUP_1], + }, + before: async () => { + const groups = await rte.data.sendCommand('xinfo', ['groups', constants.TEST_STREAM_KEY_1]); + expect(groups.length).to.eq(2); + }, + after: async () => { + const groups = await rte.data.sendCommand('xinfo', ['groups', constants.TEST_STREAM_KEY_1]); + expect(groups.length).to.eq(1); + }, + }, + { + name: 'Should delete multiple consumer group', + data: { + keyName: constants.TEST_STREAM_KEY_1, + consumerGroups: [constants.TEST_STREAM_GROUP_1, constants.TEST_STREAM_GROUP_2], + }, + before: async () => { + const groups = await rte.data.sendCommand('xinfo', ['groups', constants.TEST_STREAM_KEY_1]); + expect(groups.length).to.eq(2); + }, + after: async () => { + const groups = await rte.data.sendCommand('xinfo', ['groups', constants.TEST_STREAM_KEY_1]); + expect(groups.length).to.eq(0); + }, + }, + { + name: 'Should delete single consumer group and ignore not existing', + data: { + keyName: constants.TEST_STREAM_KEY_1, + consumerGroups: [constants.TEST_STREAM_GROUP_1, constants.getRandomString(), constants.getRandomString()], + }, + before: async () => { + const groups = await rte.data.sendCommand('xinfo', ['groups', constants.TEST_STREAM_KEY_1]); + expect(groups.length).to.eq(2); + }, + after: async () => { + const groups = await rte.data.sendCommand('xinfo', ['groups', constants.TEST_STREAM_KEY_1]); + expect(groups.length).to.eq(1); + }, + }, + { + name: 'Should return BadRequest error if key has another type', + data: { + ...validInputData, + 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: { + ...validInputData, + 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: { + ...validInputData, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + + before(async () => await rte.data.generateKeys(true)); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should remove consumer group', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "xgroup" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xgroup') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/stream/PATCH-instance-id-streams-consumer_groups.test.ts b/redisinsight/api/test/api/stream/PATCH-instance-id-streams-consumer_groups.test.ts new file mode 100644 index 0000000000..495dfaa9f8 --- /dev/null +++ b/redisinsight/api/test/api/stream/PATCH-instance-id-streams-consumer_groups.test.ts @@ -0,0 +1,182 @@ +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).patch(`/instance/${instanceId}/streams/consumer-groups`); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + name: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), + lastDeliveredId: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), +}).strict(); + +const validInputData = { + keyName: constants.TEST_STREAM_KEY_1, + name: constants.TEST_STREAM_GROUP_1, + lastDeliveredId: '$', +}; + +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/streams/consumer-groups', () => { + 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_2); + await rte.client.xadd(constants.TEST_STREAM_KEY_2, '*', 'f', 'v'); + }); + + [ + { + name: 'Should update lastDeliveredId', + data: { + keyName: constants.TEST_STREAM_KEY_1, + name: constants.TEST_STREAM_GROUP_1, + lastDeliveredId: constants.TEST_STREAM_ID_2, + }, + before: async () => { + const [group] = await rte.data.sendCommand('xinfo', ['groups', constants.TEST_STREAM_KEY_1]); + expect(group[7]).to.eq(constants.TEST_STREAM_ID_1); + }, + after: async () => { + const [group] = await rte.data.sendCommand('xinfo', ['groups', constants.TEST_STREAM_KEY_1]); + expect(group[7]).to.eq(constants.TEST_STREAM_ID_2); + }, + }, + { + name: 'Should return BadRequest error if key has another type', + data: { + ...validInputData, + 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: { + ...validInputData, + 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 group does not exists', + data: { + ...validInputData, + keyName: constants.TEST_STREAM_KEY_2, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Consumer Group with such name was not found.', + }, + }, + { + 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 () => await rte.data.generateKeys(true)); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create consumer group', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "xgroup" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xgroup') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-consumers-get.test.ts b/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-consumers-get.test.ts new file mode 100644 index 0000000000..1f62a60f60 --- /dev/null +++ b/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-consumers-get.test.ts @@ -0,0 +1,199 @@ +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/consumer-groups/consumers/get`); + +const consumerSchema = Joi.object().keys({ + name: Joi.string().required(), + idle: Joi.number().required(), + pending: Joi.number().required(), +}).strict(); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + groupName: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), +}).strict(); + +const validInputData = { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, +}; + +const responseSchema = Joi.array().items(consumerSchema).min(0).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/consumer-groups/consumers/get', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + beforeEach(async () => { + await rte.data.sendCommand('xreadgroup', [ + 'GROUP', + constants.TEST_STREAM_GROUP_1, + constants.TEST_STREAM_CONSUMER_1, + 'STREAMS', + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_ID_1, + ]); + }); + + [ + { + name: 'Should return empty array when no consumers', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_2, + }, + responseSchema, + responseBody: [], + }, + { + name: 'Should return consumers list', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + }, + responseSchema, + checkFn: ({ body }) => { + const [consumer] = body; + expect(consumer.name).to.eq(constants.TEST_STREAM_CONSUMER_1); + expect(consumer.pending).to.eq(0); + expect(consumer.idle).to.gte(0); + } + }, + { + name: 'Should return BadRequest error if key has another type', + data: { + ...validInputData, + keyName: constants.TEST_STRING_KEY_1, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should return NotFound error if group does not exists', + data: { + ...validInputData, + groupName: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Consumer Group with such name was not found.', + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + ...validInputData, + 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: { + ...validInputData, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + + before(async () => await rte.data.generateKeys(true)); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create consumer group', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "xinfo" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xinfo') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-consumers-pending_messages-ack.test.ts b/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-consumers-pending_messages-ack.test.ts new file mode 100644 index 0000000000..12435fc4a4 --- /dev/null +++ b/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-consumers-pending_messages-ack.test.ts @@ -0,0 +1,268 @@ +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/consumer-groups/consumers/pending-messages/ack`); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + groupName: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), + entries: Joi.array().items(Joi.string().required().label('entries').messages({ + 'any.required': '{#label} should not be empty', + })).required().min(1).messages({ + 'array.sparse': 'each value in entries must be a string', + }), +}).strict(); + +const validInputData = { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + entries: [constants.TEST_STREAM_ID_1], +}; + +const responseSchema = Joi.object().keys({ + affected: Joi.number().required().min(0), +}).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/consumer-groups/consumers/pending-messages/ack', () => { + requirements('!rte.crdt'); + + beforeEach(async () => { + await rte.data.generateStrings(true); + await rte.data.generateStreamsWithoutStrictMode(); + }); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + beforeEach(async () => { + await rte.data.sendCommand('xadd', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_ID_3, + constants.TEST_STREAM_FIELD_1, + constants.TEST_STREAM_VALUE_1, + ]) + await rte.data.sendCommand('xadd', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_ID_4, + constants.TEST_STREAM_FIELD_1, + constants.TEST_STREAM_VALUE_1, + ]) + await rte.data.sendCommand('xreadgroup', [ + 'GROUP', + constants.TEST_STREAM_GROUP_1, + constants.TEST_STREAM_CONSUMER_1, + 'STREAMS', + constants.TEST_STREAM_KEY_1, + '>', + ]); + await rte.data.sendCommand('xreadgroup', [ + 'GROUP', + constants.TEST_STREAM_GROUP_1, + constants.TEST_STREAM_CONSUMER_2, + 'STREAMS', + constants.TEST_STREAM_KEY_1, + '>', + ]); + }); + + [ + { + name: 'Should ack single entry', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + entries: [constants.TEST_STREAM_ID_3], + }, + responseSchema, + responseBody: { affected: 1 }, + before: async () => { + const pendingMessages = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + ]) + expect(pendingMessages.length).to.eql(2); + }, + after: async () => { + const pendingMessages = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + ]) + expect(pendingMessages.length).to.eql(1); + }, + }, + { + name: 'Should ack single entry and ignore not existing', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + entries: [constants.TEST_STREAM_ID_3, '9999-98', '9999-99'], + }, + responseSchema, + responseBody: { affected: 1 }, + before: async () => { + const pendingMessages = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + ]) + expect(pendingMessages.length).to.eql(2); + }, + after: async () => { + const pendingMessages = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + ]) + expect(pendingMessages.length).to.eql(1); + }, + }, + { + name: 'Should return affected:0 if group does not exists', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.getRandomString(), + entries: [constants.TEST_STREAM_ID_3, constants.TEST_STREAM_ID_4], + }, + responseSchema, + responseBody: { affected: 0 }, + before: async () => { + const pendingMessages = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + ]) + expect(pendingMessages.length).to.eql(2); + }, + after: async () => { + const pendingMessages = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + ]) + expect(pendingMessages.length).to.eql(2); + }, + }, + { + name: 'Should return BadRequest error if key has another type', + data: { + ...validInputData, + 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: { + ...validInputData, + 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: { + ...validInputData, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + + before(async () => await rte.data.generateKeys(true)); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create consumer group', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "xack" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xack') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-consumers-pending_messages-claim.test.ts b/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-consumers-pending_messages-claim.test.ts new file mode 100644 index 0000000000..984a8d2195 --- /dev/null +++ b/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-consumers-pending_messages-claim.test.ts @@ -0,0 +1,348 @@ +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/consumer-groups/consumers/pending-messages/claim`); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + groupName: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), + consumerName: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), + entries: Joi.array().items(Joi.string().required().label('entries').messages({ + 'any.required': '{#label} should not be empty', + })).required().min(1).messages({ + 'array.sparse': 'each value in entries must be a string', + }), + minIdleTime: Joi.number().integer().min(0), + time: Joi.number().integer(), + retryCount: Joi.number().integer().min(0), + force: Joi.boolean(), +}).strict(); + +const validInputData = { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerName: constants.TEST_STREAM_GROUP_1, + minIdleTime: 0, + entries: [constants.TEST_STREAM_ID_1], +}; + +const responseSchema = Joi.object().keys({ + affected: Joi.array().items(Joi.string()).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/consumer-groups/consumers/pending-messages/claim', () => { + requirements('!rte.crdt'); + + beforeEach(async () => { + await rte.data.generateStrings(true); + await rte.data.generateStreamsWithoutStrictMode(); + }); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + beforeEach(async () => { + await rte.data.sendCommand('xadd', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_ID_3, + constants.TEST_STREAM_FIELD_1, + constants.TEST_STREAM_VALUE_1, + ]) + await rte.data.sendCommand('xadd', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_ID_4, + constants.TEST_STREAM_FIELD_1, + constants.TEST_STREAM_VALUE_1, + ]) + await rte.data.sendCommand('xreadgroup', [ + 'GROUP', + constants.TEST_STREAM_GROUP_1, + constants.TEST_STREAM_CONSUMER_1, + 'STREAMS', + constants.TEST_STREAM_KEY_1, + '>', + ]); + await rte.data.sendCommand('xreadgroup', [ + 'GROUP', + constants.TEST_STREAM_GROUP_1, + constants.TEST_STREAM_CONSUMER_2, + 'STREAMS', + constants.TEST_STREAM_KEY_1, + '>', + ]); + }); + + [ + { + name: 'Should claim single entry', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerName: constants.TEST_STREAM_CONSUMER_2, + entries: [constants.TEST_STREAM_ID_3], + force: true, + }, + responseSchema, + responseBody: { affected: [constants.TEST_STREAM_ID_3] }, + before: async () => { + const consumerOneEntries = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + constants.TEST_STREAM_CONSUMER_1, + ]) + expect(consumerOneEntries.length).to.eql(2); + const consumerTwoEntries = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + constants.TEST_STREAM_CONSUMER_2, + ]) + expect(consumerTwoEntries.length).to.eql(0); + }, + after: async () => { + const consumerOneEntries = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + constants.TEST_STREAM_CONSUMER_1, + ]) + expect(consumerOneEntries.length).to.eql(1); + const consumerTwoEntries = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + constants.TEST_STREAM_CONSUMER_2, + ]) + expect(consumerTwoEntries.length).to.eql(1); + }, + }, + { + name: 'Should claim multiple entries', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerName: constants.TEST_STREAM_CONSUMER_2, + entries: [constants.TEST_STREAM_ID_3, constants.TEST_STREAM_ID_4], + minIdleTime: 0, + idle: 0, + retryCount: 1, + }, + responseSchema, + responseBody: { affected: [constants.TEST_STREAM_ID_3, constants.TEST_STREAM_ID_4] }, + before: async () => { + const consumerOneEntries = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + constants.TEST_STREAM_CONSUMER_1, + ]) + expect(consumerOneEntries.length).to.eql(2); + const consumerTwoEntries = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + constants.TEST_STREAM_CONSUMER_2, + ]) + expect(consumerTwoEntries.length).to.eql(0); + }, + after: async () => { + const consumerOneEntries = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + constants.TEST_STREAM_CONSUMER_1, + ]) + expect(consumerOneEntries.length).to.eql(0); + const consumerTwoEntries = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + constants.TEST_STREAM_CONSUMER_2, + ]) + expect(consumerTwoEntries.length).to.eql(2); + }, + }, + { + name: 'Should claim multiple entries out of known consumer', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerName: constants.getRandomString(), + entries: [constants.TEST_STREAM_ID_3, constants.TEST_STREAM_ID_4], + minIdleTime: 0, + time: 0, + retryCount: 1, + }, + responseSchema, + responseBody: { affected: [constants.TEST_STREAM_ID_3, constants.TEST_STREAM_ID_4] }, + before: async () => { + const consumerOneEntries = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + constants.TEST_STREAM_CONSUMER_1, + ]) + expect(consumerOneEntries.length).to.eql(2); + const consumerTwoEntries = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + constants.TEST_STREAM_CONSUMER_2, + ]) + expect(consumerTwoEntries.length).to.eql(0); + }, + after: async () => { + const consumerOneEntries = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + constants.TEST_STREAM_CONSUMER_1, + ]) + expect(consumerOneEntries.length).to.eql(0); + const consumerTwoEntries = await rte.data.sendCommand('xpending', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + '-', '+', 100, + constants.TEST_STREAM_CONSUMER_2, + ]) + expect(consumerTwoEntries.length).to.eql(0); + }, + }, + { + name: 'Should return BadRequest error if key has another type', + data: { + ...validInputData, + keyName: constants.TEST_STRING_KEY_1, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should return NotFound error if group does not exists', + data: { + ...validInputData, + groupName: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Consumer Group with such name was not found.', + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + ...validInputData, + 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: { + ...validInputData, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + + before(async () => await rte.data.generateKeys(true)); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create consumer group', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "xclaim" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xclaim') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-consumers-pending_messages-get.test.ts b/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-consumers-pending_messages-get.test.ts new file mode 100644 index 0000000000..d036731d69 --- /dev/null +++ b/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-consumers-pending_messages-get.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/consumer-groups/consumers/pending-messages/get`); + +const pendingMessageSchema = Joi.object().keys({ + id: Joi.string().required(), + consumerName: Joi.string().required(), + idle: Joi.number().required(), + delivered: Joi.number().required(), +}).strict(); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + groupName: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), + consumerName: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), + count: Joi.number(), + start: Joi.string(), + end: Joi.string(), +}).strict(); + +const validInputData = { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerName: constants.TEST_STREAM_CONSUMER_1, +}; + +const responseSchema = Joi.array().items(pendingMessageSchema).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/consumer-groups/consumers/pending-messages/get', () => { + requirements('!rte.crdt'); + + beforeEach(async () => { + await rte.data.generateStrings(true); + await rte.data.generateStreamsWithoutStrictMode(); + }); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + beforeEach(async () => { + await rte.data.sendCommand('xadd', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_ID_3, + constants.TEST_STREAM_FIELD_1, + constants.TEST_STREAM_VALUE_1, + ]) + await rte.data.sendCommand('xadd', [ + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_ID_4, + constants.TEST_STREAM_FIELD_1, + constants.TEST_STREAM_VALUE_1, + ]) + await rte.data.sendCommand('xreadgroup', [ + 'GROUP', + constants.TEST_STREAM_GROUP_1, + constants.TEST_STREAM_CONSUMER_1, + 'STREAMS', + constants.TEST_STREAM_KEY_1, + '>', + ]); + await rte.data.sendCommand('xreadgroup', [ + 'GROUP', + constants.TEST_STREAM_GROUP_1, + constants.TEST_STREAM_CONSUMER_2, + 'STREAMS', + constants.TEST_STREAM_KEY_1, + '>', + ]); + }); + + [ + { + name: 'Should return empty array when no pending messages', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_2, + consumerName: constants.TEST_STREAM_CONSUMER_2, + }, + responseSchema, + responseBody: [], + }, + { + name: 'Should return pending messages list with only 1 message', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerName: constants.TEST_STREAM_CONSUMER_1, + count: 1 + }, + responseSchema, + checkFn: ({ body }) => { + const [message] = body; + expect(body.length).to.eql(1); + expect(message.id).to.eq(constants.TEST_STREAM_ID_3); + expect(message.consumerName).to.eq(constants.TEST_STREAM_CONSUMER_1); + expect(message.idle).to.gte(0); + expect(message.delivered).to.eq(1); + } + }, + { + name: 'Should return pending messages list (2 messages)', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerName: constants.TEST_STREAM_CONSUMER_1, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.length).to.eql(2); + } + }, + { + name: 'Should return pending messages list (0 messages) filtered by end', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerName: constants.TEST_STREAM_CONSUMER_1, + start: '-', + end: '99-0', + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.length).to.eql(0); + } + }, + { + name: 'Should return pending messages list (1 messages) filtered by end', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerName: constants.TEST_STREAM_CONSUMER_1, + start: '-', + end: '300-0', + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.length).to.eql(1); + } + }, + { + name: 'Should return pending messages list (0 messages) filtered by start', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerName: constants.TEST_STREAM_CONSUMER_1, + start: '999-0', + end: '+', + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.length).to.eql(0); + } + }, + { + name: 'Should return pending messages list (1 messages) filtered by start', + data: { + keyName: constants.TEST_STREAM_KEY_1, + groupName: constants.TEST_STREAM_GROUP_1, + consumerName: constants.TEST_STREAM_CONSUMER_1, + start: '400-0', + end: '+', + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.length).to.eql(1); + } + }, + { + name: 'Should return BadRequest error if key has another type', + data: { + ...validInputData, + keyName: constants.TEST_STRING_KEY_1, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should return NotFound error if group does not exists', + data: { + ...validInputData, + groupName: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Consumer Group with such name was not found.', + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + ...validInputData, + 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: { + ...validInputData, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + + before(async () => await rte.data.generateKeys(true)); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create consumer group', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "xpending" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xpending') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-get.test.ts b/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-get.test.ts new file mode 100644 index 0000000000..3d464fcaf4 --- /dev/null +++ b/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups-get.test.ts @@ -0,0 +1,189 @@ +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/consumer-groups/get`); + +const consumerGroupSchema = Joi.object().keys({ + name: Joi.string().required(), + consumers: Joi.number().required(), + pending: Joi.number().required(), + lastDeliveredId: Joi.string().required(), + smallestPendingId: Joi.string().allow(null).required(), + greatestPendingId: Joi.string().allow(null).required(), +}); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), +}).strict(); + +const validInputData = { + keyName: constants.TEST_STREAM_KEY_1, +}; + +const responseSchema = Joi.array().items(consumerGroupSchema).min(0).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/consumer-groups/get', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should return empty array when no consumer groups', + data: { + keyName: constants.TEST_STREAM_KEY_2, + }, + responseSchema, + responseBody: [], + }, + { + name: 'Should return groups list', + data: { + keyName: constants.TEST_STREAM_KEY_1, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.length).to.eq(2); + expect(body[0].name).to.eq(constants.TEST_STREAM_GROUP_1); + expect(body[1].name).to.eq(constants.TEST_STREAM_GROUP_2); + expect(body[1].consumers).to.eq(0); + expect(body[1].pending).to.eq(0); + expect(body[1].lastDeliveredId).to.eq(constants.TEST_STREAM_ID_1); + expect(body[1].smallestPendingId).to.eq(null); + expect(body[1].greatestPendingId).to.eq(null); + } + }, + { + name: 'Should return BadRequest error if key has another type', + data: { + ...validInputData, + 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: { + ...validInputData, + 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: { + ...validInputData, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + + before(async () => await rte.data.generateKeys(true)); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create consumer group', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "xpending" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xpending') + }, + { + name: 'Should throw error if no permissions for "xinfo" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xinfo') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups.test.ts b/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups.test.ts new file mode 100644 index 0000000000..a443002535 --- /dev/null +++ b/redisinsight/api/test/api/stream/POST-instance-id-streams-consumer_groups.test.ts @@ -0,0 +1,245 @@ +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/consumer-groups`); + +const consumerGroupSchema = Joi.object().keys({ + name: Joi.string().label('consumerGroups.0.name').required(), + lastDeliveredId: Joi.string().label('consumerGroups.0.lastDeliveredId').required(), +}); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + consumerGroups: Joi.array().items(consumerGroupSchema).required().messages({ + 'array.sparse': 'entries must be either object or array', + 'array.base': 'property {#label} must be either object or array', + 'any.required': '{#label} should not be empty', + }), +}).strict(); + +const validInputData = { + keyName: constants.TEST_STREAM_KEY_1, + consumerGroups: [ + { + name: 'group-1', + lastDeliveredId: '$', + } + ], +}; + +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/consumer-groups', () => { + 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_2); + await rte.client.xadd(constants.TEST_STREAM_KEY_2, '*', 'f', 'v'); + }); + + [ + { + name: 'Should create single consumer group', + data: { + keyName: constants.TEST_STREAM_KEY_2, + consumerGroups: [ + { + name: constants.TEST_STREAM_GROUP_1, + lastDeliveredId: constants.TEST_STREAM_ID_1, + } + ], + }, + before: async () => { + const groups = await rte.data.sendCommand('xinfo', ['groups', constants.TEST_STREAM_KEY_2]); + expect(groups.length).to.eq(0); + }, + statusCode: 201, + after: async () => { + const groups = await rte.data.sendCommand('xinfo', ['groups', constants.TEST_STREAM_KEY_2]); + expect(groups).to.deep.eq([ + [ + 'name', constants.TEST_STREAM_GROUP_1, + 'consumers', 0, + 'pending', 0, + 'last-delivered-id', constants.TEST_STREAM_ID_1, + ] + ]); + }, + }, + { + name: 'Should create multiple consumer groups', + data: { + keyName: constants.TEST_STREAM_KEY_2, + consumerGroups: [ + { + name: constants.TEST_STREAM_GROUP_1, + lastDeliveredId: constants.TEST_STREAM_ID_1, + }, + { + name: constants.TEST_STREAM_GROUP_2, + lastDeliveredId: constants.TEST_STREAM_ID_1, + } + ], + }, + before: async () => { + const groups = await rte.data.sendCommand('xinfo', ['groups', constants.TEST_STREAM_KEY_2]); + expect(groups.length).to.eq(0); + }, + statusCode: 201, + after: async () => { + const groups = await rte.data.sendCommand('xinfo', ['groups', constants.TEST_STREAM_KEY_2]); + expect(groups).to.deep.eq([ + [ + 'name', constants.TEST_STREAM_GROUP_1, + 'consumers', 0, + 'pending', 0, + 'last-delivered-id', constants.TEST_STREAM_ID_1, + ], + [ + 'name', constants.TEST_STREAM_GROUP_2, + 'consumers', 0, + 'pending', 0, + 'last-delivered-id', constants.TEST_STREAM_ID_1, + ] + ]); + }, + }, + { + name: 'Should return 409 Conflict error when group exists', + data: { + keyName: constants.TEST_STREAM_KEY_1, + consumerGroups: [ + { + name: constants.TEST_STREAM_GROUP_1, + lastDeliveredId: constants.TEST_STREAM_ID_1, + } + ], + }, + statusCode: 409, + responseBody: { + statusCode: 409, + error: 'Conflict', + }, + }, + { + name: 'Should return BadRequest error if key has another type', + data: { + ...validInputData, + 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: { + ...validInputData, + 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: { + ...validInputData, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + + before(async () => await rte.data.generateKeys(true)); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create consumer group', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + statusCode: 201, + data: { + ...validInputData, + }, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "xgroup" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -xgroup') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/ws/pub-sub/pub-sub.test.ts b/redisinsight/api/test/api/ws/pub-sub/pub-sub.test.ts new file mode 100644 index 0000000000..3293a7513c --- /dev/null +++ b/redisinsight/api/test/api/ws/pub-sub/pub-sub.test.ts @@ -0,0 +1,333 @@ +import { + describe, + it, + before, + deps, + expect, + requirements, + _, sleep +} from '../../deps'; +import { Socket } from "socket.io-client"; +const { getSocket, constants, rte } = deps; + +const getClient = async (instanceId): Promise => { + return getSocket('pub-sub', { + query: { instanceId }, + }); +}; + +const subscription = { + channel: 'channel-a', + type: 's', +}; + +const subscriptionB = { + channel: 'channel-b', + type: 's', +}; + +const pSubscription = { + channel: '*', + type: 'p', +}; + +let client; + +describe('pub-sub', function () { + this.timeout(10000); + beforeEach(async () => { + client = await getClient(constants.TEST_INSTANCE_ID); + }); + + afterEach(async () => { + client.close(); + }); + + describe('Connection edge cases', () => { + it('should not crash on 100 concurrent pub-sub connections to the same db', async () => { + await Promise.all((new Array(100).fill(1)).map(() => new Promise((res, rej) => { + client.emit('subscribe', { subscriptions: [pSubscription, subscription] }, (ack) => { + expect(ack).to.eql({ status: 'ok' }); + res(ack); + }); + client.on('exception', rej); + }))); + }); + }); + + describe('Client creation', () => { + it('Should successfully create a client', async () => { + expect(client instanceof Socket).to.eql(true); + }); + it('Should successfully create a client even when incorrect instanceId provided', async () => { + const client = await getClient(constants.TEST_NOT_EXISTED_INSTANCE_ID); + expect(client instanceof Socket).to.eql(true); + await client.close(); + }); + }); + + describe('subscribe', () => { + it('Should successfully subscribe', async () => { + await new Promise((resolve) => { + client.emit('subscribe', { subscriptions: [pSubscription] }, (ack) => { + expect(ack).to.eql({ status: 'ok' }); + resolve(ack); + }) + }); + }); + it('Should return Not Found acknowledge when incorrect instanceId', async () => { + const client = await getClient(constants.TEST_NOT_EXISTED_INSTANCE_ID); + await new Promise((resolve, reject) => { + client.emit('subscribe', { subscriptions: [pSubscription] }, (ack) => { + try { + expect(ack.status).to.eql('error'); + expect(ack.error.status).to.eql(404); + expect(ack.error.message).to.eql('Invalid database instance id.'); + expect(ack.error.name).to.eql('NotFoundException'); + resolve(null); + } catch (e) { + reject(e); + } + }) + }); + }); + }); + + describe('on message', () => { + it('Should receive message on particular channel only', async () => { + await new Promise((resolve, reject) => { + client.emit('subscribe', { subscriptions: [subscription, subscriptionB] }, async (ack) => { + expect(ack).to.eql({ status: 'ok' }); + + client.on('s:channel-a', (data) => { + expect(data.count).to.be.eql(1); + expect(data.messages.length).to.be.eql(1); + const [message] = data.messages; + expect(message.channel).to.eq('channel-a'); + expect(message.message).to.eq('message-a'); + expect(message.time).to.be.a('number'); + resolve(null); + }); + + client.on('s:channel-b', (data) => { + reject(new Error('Should not receive message-a in this listener-b')) + }); + + await rte.data.sendCommand('publish', ['channel-c', 'message-c']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + }) + }); + }); + it('Should receive bunch of logs for many subscriptions', async () => { + const messages = { + 'channel-a': [], + 'channel-b': [], + '*': [], + }; + + client.on('s:channel-a', (data) => messages['channel-a'].push(...data.messages)); + client.on('s:channel-b', (data) => messages['channel-b'].push(...data.messages)); + client.on('p:*', (data) => messages['*'].push(...data.messages)); + + await new Promise((resolve) => { + client.emit('subscribe', { subscriptions: [subscription, subscriptionB, pSubscription] }, (ack) => { + expect(ack).to.eql({ status: 'ok' }); + + client.on('s:channel-b', resolve); + + rte.data.sendCommand('publish', ['channel-a', 'message-a']); + rte.data.sendCommand('publish', ['channel-a', 'message-a']); + rte.data.sendCommand('publish', ['channel-a', 'message-a']); + rte.data.sendCommand('publish', ['channel-a', 'message-a']); + rte.data.sendCommand('publish', ['channel-b', 'message-b']); + }) + }); + + await sleep(3000); + + expect(messages['channel-a'].length).to.eql(4); + messages['channel-a'].forEach(message => { + expect(message.channel).to.eql('channel-a'); + }); + expect(messages['channel-b'].length).to.eql(1); + expect(messages['*'].length).to.eql(5); + }); + }); + + describe('unsubscribe', () => { + it('Should still receive messages on subscriptions left', async () => { + const messages = { + 'channel-a': [], + 'channel-b': [], + '*': [], + }; + + client.on('s:channel-a', (data) => messages['channel-a'].push(...data.messages)); + client.on('s:channel-b', (data) => messages['channel-b'].push(...data.messages)); + client.on('p:*', (data) => messages['*'].push(...data.messages)); + + await new Promise((resolve) => { + client.emit('subscribe', { subscriptions: [subscription, subscriptionB, pSubscription] }, async (ack) => { + expect(ack).to.eql({ status: 'ok' }); + + client.on('s:channel-b', resolve); + + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-b', 'message-b']); + }) + }); + + + await new Promise((resolve) => { + client.emit('unsubscribe', { subscriptions: [subscription, pSubscription] }, async (ack) => { + expect(ack).to.eql({ status: 'ok' }); + + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-b', 'message-b']); + + client.on('s:channel-b', resolve); + }) + }); + + expect(messages['channel-a'].length).to.eql(4); + messages['channel-a'].forEach(message => { + expect(message.channel).to.eql('channel-a'); + }); + expect(messages['channel-b'].length).to.eql(2); + expect(messages['*'].length).to.eql(5); + }); + + it('Should receive bunch of messages when subscribed only', async () => { + const messages = { + 'channel-a': [], + 'channel-b': [], + '*': [], + }; + + client.on('s:channel-a', (data) => messages['channel-a'].push(...data.messages)); + client.on('s:channel-b', (data) => messages['channel-b'].push(...data.messages)); + client.on('p:*', (data) => messages['*'].push(...data.messages)); + + await new Promise((resolve) => { + client.emit('subscribe', { subscriptions: [subscription, subscriptionB, pSubscription] }, async (ack) => { + expect(ack).to.eql({ status: 'ok' }); + + client.on('s:channel-b', resolve); + + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-b', 'message-b']); + }) + }); + + + await new Promise((resolve) => { + client.emit('unsubscribe', { subscriptions: [subscription, subscriptionB, pSubscription] }, async (ack) => { + expect(ack).to.eql({ status: 'ok' }); + + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-a', 'message-a']); + await rte.data.sendCommand('publish', ['channel-b', 'message-b']); + + resolve(null); + }) + }); + + expect(messages['channel-a'].length).to.eql(4); + messages['channel-a'].forEach(message => { + expect(message.channel).to.eql('channel-a'); + }); + expect(messages['channel-b'].length).to.eql(1); + expect(messages['*'].length).to.eql(5); + }); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + it('should throw an error on connect without permissions (subscribe)', async () => { + await rte.data.setAclUserRules('~* +@all -subscribe'); + + const client = await getClient(constants.TEST_INSTANCE_ACL_ID); + + expect(client instanceof Socket).to.eql(true); + + await new Promise((resolve, reject) => { + client.emit('subscribe', { subscriptions: [subscription] }, (ack) => { + expect(ack.status).to.eql('error'); + expect(ack.error.status).to.eql(403); + expect(ack.error.message).to.have.string('NOPERM'); + resolve(null); + }) + }); + }); + + it('should throw an error on connect without permissions (psubscribe)', async () => { + await rte.data.setAclUserRules('~* +@all -psubscribe'); + + const client = await getClient(constants.TEST_INSTANCE_ACL_ID); + + expect(client instanceof Socket).to.eql(true); + + await new Promise((resolve, reject) => { + client.emit('subscribe', { subscriptions: [pSubscription] }, (ack) => { + expect(ack.status).to.eql('error'); + expect(ack.error.status).to.eql(403); + expect(ack.error.message).to.have.string('NOPERM'); + resolve(null); + }) + }); + }); + + it('should throw an error on connect without permissions (unsubscribe)', async () => { + await rte.data.setAclUserRules('~* +@all -unsubscribe'); + + const client = await getClient(constants.TEST_INSTANCE_ACL_ID); + + expect(client instanceof Socket).to.eql(true); + + await new Promise((resolve) => { + client.emit('subscribe', { subscriptions: [subscription] }, (ack) => { + expect(ack).to.deep.eql({ status: 'ok' }); + client.emit('unsubscribe', { subscriptions: [subscription] }, (ack) => { + expect(ack.status).to.eql('error'); + expect(ack.error.status).to.eql(403); + expect(ack.error.message).to.have.string('NOPERM'); + resolve(null); + }); + }); + }); + }); + + it('should throw an error on connect without permissions (punsubscribe)', async () => { + await rte.data.setAclUserRules('~* +@all -punsubscribe'); + + const client = await getClient(constants.TEST_INSTANCE_ACL_ID); + + expect(client instanceof Socket).to.eql(true); + + await new Promise((resolve) => { + client.emit('subscribe', { subscriptions: [pSubscription] }, (ack) => { + expect(ack).to.deep.eql({ status: 'ok' }); + client.emit('unsubscribe', { subscriptions: [pSubscription] }, (ack) => { + expect(ack.status).to.eql('error'); + expect(ack.error.status).to.eql(403); + expect(ack.error.message).to.have.string('NOPERM'); + resolve(null); + }); + }); + }); + }); + }); +}); diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index 80ae28a1d1..375dffe955 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -55,6 +55,7 @@ export const constants = { TEST_RTE_ON_PREMISE: process.env.TEST_RTE_ON_PREMISE ? process.env.TEST_RTE_ON_PREMISE === 'true' : true, TEST_RTE_SHARED_DATA: process.env.TEST_RTE_SHARED_DATA ? process.env.TEST_RTE_SHARED_DATA === 'true' : false, TEST_RTE_BIG_DATA: process.env.TEST_RTE_BIG_DATA ? process.env.TEST_RTE_BIG_DATA === 'true' : false, + TEST_RTE_CRDT: process.env.TEST_RTE_CRDT ? process.env.TEST_RTE_CRDT === 'true' : false, TEST_RTE_TYPE: process.env.TEST_RTE_DISCOVERY_TYPE || 'STANDALONE', TEST_RTE_HOST: process.env.TEST_RTE_DISCOVERY_HOST, TEST_RTE_PORT: process.env.TEST_RTE_DISCOVERY_PORT, @@ -165,16 +166,23 @@ export const constants = { // Redis Stream TEST_STREAM_TYPE: 'stream', TEST_STREAM_KEY_1: TEST_RUN_ID + '_stream_1' + CLUSTER_HASH_SLOT, + TEST_STREAM_KEY_2: TEST_RUN_ID + '_stream_2' + 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_ID_3: '300-0', + TEST_STREAM_ID_4: '400-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, + TEST_STREAM_GROUP_1: TEST_RUN_ID + '_stream_group_1', + TEST_STREAM_CONSUMER_1: TEST_RUN_ID + '_stream_consumer_1', + TEST_STREAM_GROUP_2: TEST_RUN_ID + '_stream_group_2', + TEST_STREAM_CONSUMER_2: TEST_RUN_ID + '_stream_consumer_2', // ReJSON-RL TEST_REJSON_TYPE: 'ReJSON-RL', @@ -214,5 +222,14 @@ export const constants = { // Plugins TEST_PLUGIN_VISUALIZATION_ID_1: uuidv4(), + // Pub/Sub + TEST_PUB_SUB_CHANNEL_1: 'channel-a', + TEST_PUB_SUB_CHANNEL_2: 'channel-b', + TEST_PUB_SUB_CHANNEL_3: 'channel-c', + TEST_PUB_SUB_P_CHANNEL_1: '*', + TEST_PUB_SUB_MESSAGE_1: 'message-a', + TEST_PUB_SUB_MESSAGE_2: 'message-b', + TEST_PUB_SUB_MESSAGE_3: 'message-c', + // etc... } diff --git a/redisinsight/api/test/helpers/data/redis.ts b/redisinsight/api/test/helpers/data/redis.ts index f383726a22..5184979fa9 100644 --- a/redisinsight/api/test/helpers/data/redis.ts +++ b/redisinsight/api/test/helpers/data/redis.ts @@ -1,10 +1,17 @@ import { get } from 'lodash'; import { constants } from '../constants'; import * as _ from 'lodash'; +import * as IORedis from 'ioredis'; export const initDataHelper = (rte) => { const client = rte.client; + const sendCommand = async (command: string, args: string[], replyEncoding = 'utf8'): Promise => { + return client.sendCommand(new IORedis.Command(command, args, { + replyEncoding, + })); + }; + const executeCommand = async (...args: string[]): Promise => { return client.nodes ? Promise.all(client.nodes('master').map(async (node) => { try { @@ -229,6 +236,40 @@ export const initDataHelper = (rte) => { } await client.xadd(constants.TEST_STREAM_KEY_1, '*', constants.TEST_STREAM_FIELD_1, constants.TEST_STREAM_VALUE_1) + await sendCommand('xgroup', [ + 'create', + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + constants.TEST_STREAM_ID_1 + ]) + await sendCommand('xgroup', [ + 'create', + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_2, + constants.TEST_STREAM_ID_1 + ]) + await client.xadd(constants.TEST_STREAM_KEY_2, '*', constants.TEST_STREAM_FIELD_1, constants.TEST_STREAM_VALUE_1) + }; + + const generateStreamsWithoutStrictMode = async (clean: boolean = false) => { + if (clean) { + await truncate(); + } + + await client.xadd(constants.TEST_STREAM_KEY_1, constants.TEST_STREAM_ID_1, constants.TEST_STREAM_FIELD_1, constants.TEST_STREAM_VALUE_1) + await sendCommand('xgroup', [ + 'create', + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_1, + constants.TEST_STREAM_ID_1 + ]) + await sendCommand('xgroup', [ + 'create', + constants.TEST_STREAM_KEY_1, + constants.TEST_STREAM_GROUP_2, + constants.TEST_STREAM_ID_1 + ]) + await client.xadd(constants.TEST_STREAM_KEY_2, constants.TEST_STREAM_ID_1, constants.TEST_STREAM_FIELD_1, constants.TEST_STREAM_VALUE_1) }; const generateHugeStream = async (number: number = 100000, clean: boolean) => { @@ -329,6 +370,7 @@ export const initDataHelper = (rte) => { } return { + sendCommand, executeCommand, executeCommandAll, setAclUserRules, @@ -340,6 +382,9 @@ export const initDataHelper = (rte) => { generateNKeys, generateNReJSONs, generateNTimeSeries, + generateStrings, + generateStreams, + generateStreamsWithoutStrictMode, generateNStreams, generateNGraphs, getClientNodes, diff --git a/redisinsight/api/test/helpers/redis.ts b/redisinsight/api/test/helpers/redis.ts index 98aa29a81c..bd44806f65 100644 --- a/redisinsight/api/test/helpers/redis.ts +++ b/redisinsight/api/test/helpers/redis.ts @@ -185,6 +185,7 @@ export const initRTE = async () => { cloud: !!constants.TEST_CLOUD_RTE, sharedData: constants.TEST_RTE_SHARED_DATA, bigData: constants.TEST_RTE_BIG_DATA, + crdt: constants.TEST_RTE_CRDT, nodes: [], }; diff --git a/redisinsight/api/test/helpers/test.ts b/redisinsight/api/test/helpers/test.ts index 8d0c83679a..9b6c94f3da 100644 --- a/redisinsight/api/test/helpers/test.ts +++ b/redisinsight/api/test/helpers/test.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as chai from 'chai'; import * as Joi from 'joi'; import * as diff from 'object-diff'; -import { cloneDeep, isMatch, isObject, set } from 'lodash'; +import { cloneDeep, isMatch, isObject, set, isArray } from 'lodash'; import { generateInvalidDataArray } from './test/dataGenerator'; export { _, fs } @@ -84,6 +84,10 @@ export const validateApiCall = async function ({ */ export const checkResponseBody = (body, expected) => { try { + if (isArray(expected)) { + return expect(body).to.deep.eq(expected); + } + if (isObject(expected)) { return expect(isMatch(body, expected)).to.eql(true); } diff --git a/redisinsight/api/test/test-runs/re-crdt/.env b/redisinsight/api/test/test-runs/re-crdt/.env index 0499f8d08a..91b052aa7e 100644 --- a/redisinsight/api/test/test-runs/re-crdt/.env +++ b/redisinsight/api/test/test-runs/re-crdt/.env @@ -3,3 +3,4 @@ TEST_RE_USER=demo@redislabs.com TEST_RE_PASS=123456 TEST_REDIS_PORT=12000 TEST_RTE_SHARED_DATA=true +TEST_RTE_CRDT=true diff --git a/redisinsight/ui/src/App.tsx b/redisinsight/ui/src/App.tsx index b6d8db4a24..0ed7b79faf 100644 --- a/redisinsight/ui/src/App.tsx +++ b/redisinsight/ui/src/App.tsx @@ -4,13 +4,12 @@ import { Provider, useSelector } from 'react-redux' import { EuiPage, EuiPageBody } from '@elastic/eui' import { appInfoSelector } from 'uiSrc/slices/app/info' -import { BuildType } from 'uiSrc/constants/env' import { PagePlaceholder } from 'uiSrc/components' import Router from './Router' import store from './slices/store' import { Theme } from './constants' import { themeService } from './services' -import { Config, MonitorConfig, NavigationMenu, Notifications, ShortcutsFlyout } from './components' +import { Config, GlobalSubscriptions, NavigationMenu, Notifications, ShortcutsFlyout } from './components' import { ThemeProvider } from './contexts/themeContext' import MainComponent from './components/main/MainComponent' @@ -33,15 +32,15 @@ const AppWrapper = ({ children }: { children?: ReactElement }) => ( ) const App = () => { - const { loading: serverLoading, server } = useSelector(appInfoSelector) + const { loading: serverLoading } = useSelector(appInfoSelector) return (
{ serverLoading ? : ( - - + + diff --git a/redisinsight/ui/src/assets/img/dark_logo.svg b/redisinsight/ui/src/assets/img/dark_logo.svg index 1074ca8426..b7f08f91df 100644 --- a/redisinsight/ui/src/assets/img/dark_logo.svg +++ b/redisinsight/ui/src/assets/img/dark_logo.svg @@ -1,112 +1,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/redisinsight/ui/src/assets/img/icons/user_in_circle.svg b/redisinsight/ui/src/assets/img/icons/user_in_circle.svg new file mode 100644 index 0000000000..fd2dd07d3e --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/user_in_circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/light_logo.svg b/redisinsight/ui/src/assets/img/light_logo.svg index be8885883d..92f480eb93 100644 --- a/redisinsight/ui/src/assets/img/light_logo.svg +++ b/redisinsight/ui/src/assets/img/light_logo.svg @@ -1,113 +1,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/redisinsight/ui/src/assets/img/pub-sub/not-subscribed-lt.svg b/redisinsight/ui/src/assets/img/pub-sub/not-subscribed-lt.svg new file mode 100644 index 0000000000..7afb1ae527 --- /dev/null +++ b/redisinsight/ui/src/assets/img/pub-sub/not-subscribed-lt.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/pub-sub/not-subscribed.svg b/redisinsight/ui/src/assets/img/pub-sub/not-subscribed.svg new file mode 100644 index 0000000000..772536bff4 --- /dev/null +++ b/redisinsight/ui/src/assets/img/pub-sub/not-subscribed.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/pub-sub/subscribed-lt.svg b/redisinsight/ui/src/assets/img/pub-sub/subscribed-lt.svg new file mode 100644 index 0000000000..13dcf228ab --- /dev/null +++ b/redisinsight/ui/src/assets/img/pub-sub/subscribed-lt.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/pub-sub/subscribed.svg b/redisinsight/ui/src/assets/img/pub-sub/subscribed.svg new file mode 100644 index 0000000000..af10940ac0 --- /dev/null +++ b/redisinsight/ui/src/assets/img/pub-sub/subscribed.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/sidebar/pubsub.svg b/redisinsight/ui/src/assets/img/sidebar/pubsub.svg new file mode 100644 index 0000000000..e0d59e8207 --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/pubsub.svg @@ -0,0 +1,4 @@ + + + + diff --git a/redisinsight/ui/src/assets/img/sidebar/pubsub_active.svg b/redisinsight/ui/src/assets/img/sidebar/pubsub_active.svg new file mode 100644 index 0000000000..d914b8ec48 --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/pubsub_active.svg @@ -0,0 +1,4 @@ + + + + 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 8d3dc6e904..1f7d10887e 100644 --- a/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx +++ b/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx @@ -17,15 +17,16 @@ import { sendCliClusterCommandAction, processUnsupportedCommand, processUnrepeatableNumber, - processMonitorCommand, } from 'uiSrc/slices/cli/cli-output' -import { CommandMonitor } from 'uiSrc/constants' +import { CommandMonitor, CommandPSubscribe, Pages } 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/instances' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { checkUnsupportedCommand, clearOutput, cliCommandOutput } from 'uiSrc/utils/cliHelper' +import { cliTexts } from 'uiSrc/constants/cliOutput' +import { showMonitor } from 'uiSrc/slices/cli/monitor' import { SendClusterCommandDto } from 'apiSrc/modules/cli/dto/cli.dto' import CliBody from './CliBody' @@ -78,9 +79,17 @@ const CliBodyWrapper = () => { return } - // Flow if monitor command was executed + // Flow if MONITOR command was executed if (checkUnsupportedCommand([CommandMonitor.toLowerCase()], commandLine)) { - dispatch(processMonitorCommand(commandLine, resetCommand)) + dispatch(concatToOutput(cliTexts.MONITOR_COMMAND_CLI(() => { dispatch(showMonitor()) }))) + resetCommand() + return + } + + // Flow if PSUBSCRIBE command was executed + if (checkUnsupportedCommand([CommandPSubscribe.toLowerCase()], commandLine)) { + dispatch(concatToOutput(cliTexts.PSUBSCRIBE_COMMAND_CLI(Pages.pubSub(instanceId)))) + resetCommand() return } diff --git a/redisinsight/ui/src/components/config/Config.tsx b/redisinsight/ui/src/components/config/Config.tsx index 5f707de5cb..4cceb306aa 100644 --- a/redisinsight/ui/src/components/config/Config.tsx +++ b/redisinsight/ui/src/components/config/Config.tsx @@ -15,7 +15,8 @@ import { setAnalyticsIdentified, } from 'uiSrc/slices/app/info' -import { checkIsAnalyticsGranted, getTelemetryService } from 'uiSrc/telemetry' +import { getTelemetryService } from 'uiSrc/telemetry' +import { checkIsAnalyticsGranted } from 'uiSrc/telemetry/checkAnalytics' import { setFavicon, isDifferentConsentsExists } from 'uiSrc/utils' import { fetchUnsupportedCliCommandsAction } from 'uiSrc/slices/cli/cli-settings' import { fetchRedisCommandsInfo } from 'uiSrc/slices/app/redis-commands' diff --git a/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx b/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx index 7ee60cc5e7..a8998451cc 100644 --- a/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx +++ b/redisinsight/ui/src/components/database-overview/DatabaseOverviewWrapper.tsx @@ -10,14 +10,14 @@ import { ThemeContext } from 'uiSrc/contexts/themeContext' import { getOverviewMetrics } from './components/OverviewMetrics' -const TIMEOUT_TO_GET_INFO = process.env.NODE_ENV !== 'development' ? 5000 : 100000 +const TIMEOUT_TO_GET_INFO = process.env.NODE_ENV !== 'development' ? 5000 : 10000000 interface IProps { windowDimensions: number } const DatabaseOverviewWrapper = ({ windowDimensions } :IProps) => { let interval: NodeJS.Timeout const { theme } = useContext(ThemeContext) - const { id: connectedInstanceId = '', modules = [], isRediStack } = useSelector(connectedInstanceSelector) + const { id: connectedInstanceId = '', modules = [], isRediStack, db } = useSelector(connectedInstanceSelector) const overview = useSelector(connectedInstanceOverviewSelector) const dispatch = useDispatch() @@ -38,7 +38,7 @@ const DatabaseOverviewWrapper = ({ windowDimensions } :IProps) => { return ( diff --git a/redisinsight/ui/src/components/database-overview/components/OverviewMetrics/OverviewMetrics.tsx b/redisinsight/ui/src/components/database-overview/components/OverviewMetrics/OverviewMetrics.tsx index 874291fc04..680f94147c 100644 --- a/redisinsight/ui/src/components/database-overview/components/OverviewMetrics/OverviewMetrics.tsx +++ b/redisinsight/ui/src/components/database-overview/components/OverviewMetrics/OverviewMetrics.tsx @@ -1,6 +1,6 @@ import React, { ReactNode } from 'react' import { EuiLoadingSpinner } from '@elastic/eui' -import { isArray } from 'lodash' +import { isArray, isUndefined, toNumber } from 'lodash' import { formatBytes, Nullable, truncateNumberToRange, truncatePercentage } from 'uiSrc/utils' import { Theme } from 'uiSrc/constants' @@ -25,38 +25,40 @@ import { import styles from './styles.module.scss' interface Props { - theme: string; + theme: string + db?: number items: { version: string, - usedMemory?: Nullable; - totalKeys?: Nullable; - connectedClients?: Nullable; - opsPerSecond?: Nullable; - networkInKbps?: Nullable; - networkOutKbps?: Nullable; - cpuUsagePercentage?: Nullable; - }; + usedMemory?: Nullable + totalKeys?: Nullable + connectedClients?: Nullable + opsPerSecond?: Nullable + networkInKbps?: Nullable + networkOutKbps?: Nullable + cpuUsagePercentage?: Nullable + totalKeysPerDb?: Nullable<{ [key: string]: number }> + } } export interface IMetric { - id: string; - content: ReactNode; - value: any; - unavailableText: string; - title: string; + id: string + content: ReactNode + value: any + unavailableText: string + title: string tooltip: { - title?: string; - icon: Nullable; - content: ReactNode | string; - }; - loading?: boolean; - groupId?: string; - icon?: Nullable; - className?: string; + title?: string + icon: Nullable + content: ReactNode | string + } + loading?: boolean + groupId?: string + icon?: Nullable + className?: string children?: Array } -export const getOverviewMetrics = ({ theme, items }: Props): Array => { +export const getOverviewMetrics = ({ theme, items, db = 0 }: Props): Array => { const { usedMemory, totalKeys, @@ -64,7 +66,8 @@ export const getOverviewMetrics = ({ theme, items }: Props): Array => { cpuUsagePercentage, opsPerSecond, networkInKbps, - networkOutKbps + networkOutKbps, + totalKeysPerDb = {}, } = items const availableItems: Array = [] @@ -164,7 +167,7 @@ export const getOverviewMetrics = ({ theme, items }: Props): Array => { }, } - if (opsPerSecond !== undefined && (networkInKbps !== undefined || networkOutKbps !== undefined)) { + if (!isUndefined(opsPerSecond)) { opsPerSecItem.children = [ { id: 'commands-per-sec-tip', @@ -208,7 +211,7 @@ export const getOverviewMetrics = ({ theme, items }: Props): Array => { }) // Total keys - availableItems.push({ + const totalKeysItem: any = { id: 'overview-total-keys', value: totalKeys, unavailableText: 'Total Keys are not available', @@ -220,7 +223,38 @@ export const getOverviewMetrics = ({ theme, items }: Props): Array => { }, icon: theme === Theme.Dark ? KeyDarkIcon : KeyLightIcon, content: truncateNumberToRange(totalKeys || 0), - }) + } + + // keys in the logical database + const dbKeysCount = totalKeysPerDb?.[`db${db || 0}`] + if (!isUndefined(dbKeysCount) && dbKeysCount < toNumber(totalKeys)) { + totalKeysItem.children = [ + { + id: 'total-keys-tip', + value: totalKeys, + unavailableText: 'Total Keys are not available', + title: 'Total Keys', + tooltip: { + title: 'Total Keys', + content: ({numberWithSpaces(totalKeys || 0)}), + }, + content: ({numberWithSpaces(totalKeys || 0)}), + }, + { + id: 'overview-db-total-keys', + title: 'Keys', + value: dbKeysCount, + content: ( + <> + db{db || 0}: + {numberWithSpaces(dbKeysCount || 0)} + + ), + }, + ] + } + + availableItems.push(totalKeysItem) const getConnectedClient = (connectedClients: number = 0) => (Number.isInteger(connectedClients) ? connectedClients : `~${Math.round(connectedClients)}`) diff --git a/redisinsight/ui/src/components/database-overview/styles.module.scss b/redisinsight/ui/src/components/database-overview/styles.module.scss index e485e0cad4..2ca38efd4e 100644 --- a/redisinsight/ui/src/components/database-overview/styles.module.scss +++ b/redisinsight/ui/src/components/database-overview/styles.module.scss @@ -73,6 +73,10 @@ .commandsPerSecTip { margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } .moreInfoOverviewIcon { margin-right: 8px; width: auto !important; diff --git a/redisinsight/ui/src/components/global-subscriptions/GlobalSubscriptions.tsx b/redisinsight/ui/src/components/global-subscriptions/GlobalSubscriptions.tsx new file mode 100644 index 0000000000..eb756f5ee0 --- /dev/null +++ b/redisinsight/ui/src/components/global-subscriptions/GlobalSubscriptions.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import { MonitorConfig, PubSubConfig } from 'uiSrc/components' + +const GlobalSubscriptions = () => ( + <> + + + +) + +export default GlobalSubscriptions diff --git a/redisinsight/ui/src/components/global-subscriptions/index.ts b/redisinsight/ui/src/components/global-subscriptions/index.ts new file mode 100644 index 0000000000..90e11f281c --- /dev/null +++ b/redisinsight/ui/src/components/global-subscriptions/index.ts @@ -0,0 +1,3 @@ +import GlobalSubscriptions from './GlobalSubscriptions' + +export default GlobalSubscriptions diff --git a/redisinsight/ui/src/components/index.ts b/redisinsight/ui/src/components/index.ts index 8a2825d1db..21c6e775e5 100644 --- a/redisinsight/ui/src/components/index.ts +++ b/redisinsight/ui/src/components/index.ts @@ -15,6 +15,8 @@ import { ConsentsSettings, ConsentsSettingsPopup } from './consents-settings' import KeyboardShortcut from './keyboard-shortcut/KeyboardShortcut' import ShortcutsFlyout from './shortcuts-flyout/ShortcutsFlyout' import MonitorConfig from './monitor-config' +import PubSubConfig from './pub-sub-config' +import GlobalSubscriptions from './global-subscriptions' import MonitorWrapper from './monitor' import PagePlaceholder from './page-placeholder' @@ -36,6 +38,8 @@ export { AdvancedSettings, KeyboardShortcut, MonitorConfig, + PubSubConfig, + GlobalSubscriptions, MonitorWrapper, ShortcutsFlyout, PagePlaceholder, diff --git a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts index 4b27990a5c..2bb821c78e 100644 --- a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts @@ -11,6 +11,7 @@ import { } from 'uiSrc/pages' import WorkbenchPage from 'uiSrc/pages/workbench' import SlowLogPage from 'uiSrc/pages/slowLog' +import PubSubPage from 'uiSrc/pages/pubSub' import COMMON_ROUTES from './commonRoutes' @@ -30,6 +31,11 @@ const INSTANCE_ROUTES: IRoute[] = [ path: Pages.slowLog(':instanceId'), component: SlowLogPage, }, + { + pageName: PageNames.pubSub, + path: Pages.pubSub(':instanceId'), + component: PubSubPage, + }, ] const ROUTES: IRoute[] = [ diff --git a/redisinsight/ui/src/components/main-router/constants/redisStackRoutes.ts b/redisinsight/ui/src/components/main-router/constants/redisStackRoutes.ts index 992d1700ff..d876d5529a 100644 --- a/redisinsight/ui/src/components/main-router/constants/redisStackRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/redisStackRoutes.ts @@ -4,6 +4,7 @@ import { } from 'uiSrc/pages' import WorkbenchPage from 'uiSrc/pages/workbench' import SlowLogPage from 'uiSrc/pages/slowLog' +import PubSubPage from 'uiSrc/pages/pubSub' import EditConnection from 'uiSrc/pages/redisStack/components/edit-connection' import COMMON_ROUTES from './commonRoutes' @@ -26,6 +27,12 @@ const INSTANCE_ROUTES: IRoute[] = [ path: Pages.slowLog(':instanceId'), component: SlowLogPage, }, + { + pageName: PageNames.pubSub, + protected: true, + path: Pages.pubSub(':instanceId'), + component: PubSubPage, + }, ] const ROUTES: IRoute[] = [ diff --git a/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.tsx b/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.tsx index 712fd5b830..39d33687ad 100644 --- a/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.tsx +++ b/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.tsx @@ -4,7 +4,7 @@ import { EuiTextColor } from '@elastic/eui' import { CellMeasurer, List, CellMeasurerCache, ListRowProps } from 'react-virtualized' import { getFormatTime } from 'uiSrc/utils' -import { DEFAULT_TEXT } from 'uiSrc/components/notifications' +import { DEFAULT_ERROR_TEXT } from 'uiSrc/components/notifications' import styles from 'uiSrc/components/monitor/Monitor/styles.module.scss' import 'react-virtualized/styles.css' @@ -80,7 +80,7 @@ const MonitorOutputList = (props: Props) => { )} {isError && ( - {message ?? DEFAULT_TEXT} + {message ?? DEFAULT_ERROR_TEXT} )}
)} diff --git a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx index c0c067ea1a..118c11783d 100644 --- a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx +++ b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx @@ -2,6 +2,7 @@ import { cloneDeep } from 'lodash' import React from 'react' import { BuildType } from 'uiSrc/constants/env' import { EXTERNAL_LINKS } from 'uiSrc/constants/links' +import { appInfoSelector } from 'uiSrc/slices/app/info' import { cleanup, mockedStore, render, screen, fireEvent } from 'uiSrc/utils/test-utils' import NavigationMenu from './NavigationMenu' @@ -13,27 +14,60 @@ beforeEach(() => { store.clearActions() }) +const mockAppInfoSelector = jest.requireActual('uiSrc/slices/app/info') + +jest.mock('uiSrc/slices/app/info', () => ({ + ...jest.requireActual('uiSrc/slices/app/info'), + appInfoSelector: jest.fn().mockReturnValue({ + server: {} + }) +})) + describe('NavigationMenu', () => { describe('without connectedInstance', () => { it('should render', () => { - expect(render()).toBeTruthy() + (appInfoSelector as jest.Mock).mockImplementation(() => ({ + ...mockAppInfoSelector, + server: { + buildType: BuildType.DockerOnPremise + } + })) + expect(render()).toBeTruthy() }) it('shouldn\'t render private routes', () => { - render() + (appInfoSelector as jest.Mock).mockImplementation(() => ({ + ...mockAppInfoSelector, + server: { + buildType: BuildType.DockerOnPremise + } + })) + render() expect(screen.queryByTestId('browser-page-btn"')).not.toBeInTheDocument() expect(screen.queryByTestId('workbench-page-btn')).not.toBeInTheDocument() }) it('should render help menu', () => { - render() + (appInfoSelector as jest.Mock).mockImplementation(() => ({ + ...mockAppInfoSelector, + server: { + buildType: BuildType.RedisStack + } + })) + render() expect(screen.getByTestId('help-menu-button')).toBeTruthy() }) it('should render help menu items with proper links', () => { - render() + (appInfoSelector as jest.Mock).mockImplementation(() => ({ + ...mockAppInfoSelector, + server: { + buildType: BuildType.RedisStack + } + })) + render() fireEvent.click(screen.getByTestId('help-menu-button')) @@ -62,24 +96,48 @@ describe('NavigationMenu', () => { }) it('should render', () => { - expect(render()).toBeTruthy() + (appInfoSelector as jest.Mock).mockImplementation(() => ({ + ...mockAppInfoSelector, + server: { + buildType: BuildType.DockerOnPremise + } + })) + expect(render()).toBeTruthy() }) it('should render private routes with instanceId', () => { - render() + (appInfoSelector as jest.Mock).mockImplementation(() => ({ + ...mockAppInfoSelector, + server: { + buildType: BuildType.DockerOnPremise + } + })) + render() expect(screen.findByTestId('browser-page-btn')).toBeTruthy() expect(screen.findByTestId('workbench-page-btn')).toBeTruthy() }) it('should render public routes', () => { - render() + (appInfoSelector as jest.Mock).mockImplementation(() => ({ + ...mockAppInfoSelector, + server: { + buildType: BuildType.DockerOnPremise + } + })) + render() expect(screen.getByTestId('settings-page-btn')).toBeTruthy() }) it('should render github btn with proper link', () => { - const { container } = render() + (appInfoSelector as jest.Mock).mockImplementation(() => ({ + ...mockAppInfoSelector, + server: { + buildType: BuildType.DockerOnPremise + } + })) + const { container } = render() const githubBtn = container.querySelector('[data-test-subj="github-repo-btn"]') expect(githubBtn).toBeTruthy() diff --git a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx index e0a7dd85d2..087441d28c 100644 --- a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx +++ b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx @@ -22,7 +22,12 @@ import { PageNames, Pages } from 'uiSrc/constants' import { EXTERNAL_LINKS } from 'uiSrc/constants/links' import { getRouterLinkProps } from 'uiSrc/services' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import { appElectronInfoSelector, setReleaseNotesViewed, setShortcutsFlyoutState } from 'uiSrc/slices/app/info' +import { + appElectronInfoSelector, + appInfoSelector, + setReleaseNotesViewed, + setShortcutsFlyoutState +} from 'uiSrc/slices/app/info' import LogoSVG from 'uiSrc/assets/img/logo.svg' import SettingsSVG from 'uiSrc/assets/img/sidebar/settings.svg' import SettingsActiveSVG from 'uiSrc/assets/img/sidebar/settings_active.svg' @@ -32,6 +37,8 @@ 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 PubSubSVG from 'uiSrc/assets/img/sidebar/pubsub.svg' +import PubSubActiveSVG from 'uiSrc/assets/img/sidebar/pubsub_active.svg' import GithubSVG from 'uiSrc/assets/img/sidebar/github.svg' import Divider from 'uiSrc/components/divider/Divider' @@ -41,6 +48,7 @@ import styles from './styles.module.scss' const workbenchPath = `/${PageNames.workbench}` const browserPath = `/${PageNames.browser}` const slowLogPath = `/${PageNames.slowLog}` +const pubSubPath = `/${PageNames.pubSub}` interface INavigations { isActivePage: boolean; @@ -53,11 +61,7 @@ interface INavigations { getIconType: () => string; } -interface IProps { - buildType: BuildType -} - -const NavigationMenu = ({ buildType }: IProps) => { +const NavigationMenu = () => { const history = useHistory() const location = useLocation() const dispatch = useDispatch() @@ -67,23 +71,13 @@ const NavigationMenu = ({ buildType }: IProps) => { const { id: connectedInstanceId = '' } = useSelector(connectedInstanceSelector) const { isReleaseNotesViewed } = useSelector(appElectronInfoSelector) + const { server } = useSelector(appInfoSelector) useEffect(() => { setActivePage(`/${last(location.pathname.split('/'))}`) }, [location]) - const handleGoSettingsPage = () => { - history.push(Pages.settings) - } - const handleGoWorkbenchPage = () => { - history.push(Pages.workbench(connectedInstanceId)) - } - const handleGoBrowserPage = () => { - history.push(Pages.browser(connectedInstanceId)) - } - const handleGoSlowLogPage = () => { - history.push(Pages.slowLog(connectedInstanceId)) - } + const handleGoPage = (page: string) => history.push(page) const onKeyboardShortcutClick = () => { setIsHelpMenuActive(false) @@ -95,7 +89,7 @@ const NavigationMenu = ({ buildType }: IProps) => { tooltipText: 'Browser', isActivePage: activePage === browserPath, ariaLabel: 'Browser page button', - onClick: handleGoBrowserPage, + onClick: () => handleGoPage(Pages.browser(connectedInstanceId)), dataTestId: 'browser-page-btn', connectedInstanceId, getClassName() { @@ -108,7 +102,7 @@ const NavigationMenu = ({ buildType }: IProps) => { { tooltipText: 'Workbench', ariaLabel: 'Workbench page button', - onClick: handleGoWorkbenchPage, + onClick: () => handleGoPage(Pages.workbench(connectedInstanceId)), dataTestId: 'workbench-page-btn', connectedInstanceId, isActivePage: activePage === workbenchPath, @@ -122,7 +116,7 @@ const NavigationMenu = ({ buildType }: IProps) => { { tooltipText: 'Slow Log', ariaLabel: 'SlowLog page button', - onClick: handleGoSlowLogPage, + onClick: () => handleGoPage(Pages.slowLog(connectedInstanceId)), dataTestId: 'slowlog-page-btn', connectedInstanceId, isActivePage: activePage === slowLogPath, @@ -133,13 +127,27 @@ const NavigationMenu = ({ buildType }: IProps) => { return this.isActivePage ? SlowLogActiveSVG : SlowLogSVG }, }, + { + tooltipText: 'Pub/Sub', + ariaLabel: 'Pub/Sub page button', + onClick: () => handleGoPage(Pages.pubSub(connectedInstanceId)), + dataTestId: 'pub-sub-page-btn', + connectedInstanceId, + isActivePage: activePage === pubSubPath, + getClassName() { + return cx(styles.navigationButton, { [styles.active]: this.isActivePage }) + }, + getIconType() { + return this.isActivePage ? PubSubActiveSVG : PubSubSVG + }, + }, ] const publicRoutes: INavigations[] = [ { tooltipText: 'Settings', ariaLabel: 'Settings page button', - onClick: handleGoSettingsPage, + onClick: () => handleGoPage(Pages.settings), dataTestId: 'settings-page-btn', isActivePage: activePage === Pages.settings, getClassName() { @@ -255,11 +263,11 @@ const NavigationMenu = ({ buildType }: IProps) => {
- + diff --git a/redisinsight/ui/src/components/navigation-menu/styles.module.scss b/redisinsight/ui/src/components/navigation-menu/styles.module.scss index fd8963bb2c..43093ad44a 100644 --- a/redisinsight/ui/src/components/navigation-menu/styles.module.scss +++ b/redisinsight/ui/src/components/navigation-menu/styles.module.scss @@ -196,3 +196,12 @@ $sideBarWidth: 60px; font-size: 13px !important; line-height: 1.35 !important; } + +.logo { + &:hover { + transform: translateY(-1px); + } + &:active { + transform: translateY(1px); + } +} diff --git a/redisinsight/ui/src/components/notifications/Notifications.tsx b/redisinsight/ui/src/components/notifications/Notifications.tsx index 8ecd1e59ff..c854afbf89 100644 --- a/redisinsight/ui/src/components/notifications/Notifications.tsx +++ b/redisinsight/ui/src/components/notifications/Notifications.tsx @@ -22,7 +22,7 @@ import errorMessages from './error-messages' import styles from './styles.module.scss' -export const DEFAULT_TEXT = 'Something went wrong.' +export const DEFAULT_ERROR_TEXT = 'Something went wrong.' const Notifications = () => { const messagesData = useSelector(messagesSelector) @@ -82,7 +82,7 @@ const Notifications = () => { }) const getErrorsToasts = (errors: IError[]) => - errors.map(({ id = '', message = DEFAULT_TEXT, instanceId = '', name }) => { + errors.map(({ id = '', message = DEFAULT_ERROR_TEXT, instanceId = '', name }) => { if (ApiEncryptionErrors.includes(name)) { return errorMessages.ENCRYPTION(id, () => removeToast({ id }), instanceId) } diff --git a/redisinsight/ui/src/components/notifications/success-messages.tsx b/redisinsight/ui/src/components/notifications/success-messages.tsx index e081845b29..de78778fed 100644 --- a/redisinsight/ui/src/components/notifications/success-messages.tsx +++ b/redisinsight/ui/src/components/notifications/success-messages.tsx @@ -119,4 +119,27 @@ export default { ), group: 'upgrade' }), + // only one message is being processed at the moment + MESSAGE_ACTION: (message: string, actionName: string) => ({ + title: ( + <> + Message has been + {' '} + {actionName} + + ), + message: ( + <> + {message} + {' '} + has been successfully + {' '} + {actionName}. + + ), + }), + NO_CLAIMED_MESSAGES: () => ({ + title: 'No messages claimed', + message: 'No messages exceed the minimum idle time.', + }) } diff --git a/redisinsight/ui/src/components/page-header/PageHeader.module.scss b/redisinsight/ui/src/components/page-header/PageHeader.module.scss index 626067c7ed..246ea84f29 100644 --- a/redisinsight/ui/src/components/page-header/PageHeader.module.scss +++ b/redisinsight/ui/src/components/page-header/PageHeader.module.scss @@ -1,8 +1,6 @@ -@import '@elastic/eui/src/global_styling/index'; +@import "@elastic/eui/src/global_styling/index"; .pageHeader { - background-color: var(--euiColorEmptyShade); - border-bottom: 1px solid var(--euiColorLightShade); } .pageHeaderTop { @@ -10,16 +8,17 @@ justify-content: space-between; align-items: center; width: 100%; - padding: 8px 16px; - @include euiBreakpoint('s', 'xs') { - flex-direction: column-reverse; - > div { - width: 100%; - } - .pageHeaderLogo { - display: flex; - justify-content: center; - } + padding: 12px 4px 4px 18px; + + @include euiBreakpoint("m", "l", "xl") { + padding: 8px 14px; + } +} + +.title { + font-size: 24px; + @include euiBreakpoint("m", "l", "xl") { + padding-left: 12px; } } diff --git a/redisinsight/ui/src/components/page-header/PageHeader.tsx b/redisinsight/ui/src/components/page-header/PageHeader.tsx index be400cb922..64bb2f286f 100644 --- a/redisinsight/ui/src/components/page-header/PageHeader.tsx +++ b/redisinsight/ui/src/components/page-header/PageHeader.tsx @@ -41,7 +41,7 @@ const PageHeader = ({ title, subtitle, children }: Props) => {
- +

{title}

diff --git a/redisinsight/ui/src/components/page-placeholder/PagePlaceholder.tsx b/redisinsight/ui/src/components/page-placeholder/PagePlaceholder.tsx index 10bcf4641f..ad3e3bb1e6 100644 --- a/redisinsight/ui/src/components/page-placeholder/PagePlaceholder.tsx +++ b/redisinsight/ui/src/components/page-placeholder/PagePlaceholder.tsx @@ -3,10 +3,15 @@ import { ReactComponent as LogoIcon } from 'uiSrc/assets/img/logo.svg' import { EuiLoadingLogo, EuiEmptyPrompt } from '@elastic/eui' const PagePlaceholder = () => ( - } - titleSize="s" - /> + <> + { process.env.NODE_ENV !== 'development' && ( + } + titleSize="s" + /> + )} + + ) export default PagePlaceholder diff --git a/redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.spec.tsx b/redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.spec.tsx new file mode 100644 index 0000000000..6d3cb2cdef --- /dev/null +++ b/redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.spec.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import PopoverItemEditor, { Props } from './PopoverItemEditor' + +const mockedProps = mock() + +describe('PopoverItemEditor', () => { + it('should render', () => { + expect( + render( + + <> + + ) + ).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.tsx b/redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.tsx new file mode 100644 index 0000000000..124135d7da --- /dev/null +++ b/redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.tsx @@ -0,0 +1,128 @@ +import React, { + FormEvent, + useEffect, + useState, +} from 'react' + +import { + EuiButton, + EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiForm, + EuiPopover, +} from '@elastic/eui' +import styles from './styles.module.scss' + +export interface Props { + children: React.ReactElement + className?: string + isOpen?: boolean + onOpen: () => void + onApply: () => void + onDecline?: () => void + isLoading?: boolean + isDisabled?: boolean + declineOnUnmount?: boolean + btnTestId?: string + btnIconType?: string +} + +const PopoverItemEditor = (props: Props) => { + const { + isOpen = false, + onOpen, + onDecline, + onApply, + children, + isLoading, + declineOnUnmount = true, + isDisabled, + btnTestId, + btnIconType, + className + } = props + const [isPopoverOpen, setIsPopoverOpen] = useState(isOpen) + + useEffect(() => + // componentWillUnmount + () => { + declineOnUnmount && handleDecline() + }, + []) + + useEffect(() => { + setIsPopoverOpen(isOpen) + }, [isOpen]) + + const onFormSubmit = (e: FormEvent) => { + e.preventDefault() + handleApply() + } + + const handleApply = (): void => { + setIsPopoverOpen(false) + onApply() + } + + const handleDecline = () => { + setIsPopoverOpen(false) + onDecline?.() + } + + const handleButtonClick = (e: React.MouseEvent) => { + e.stopPropagation() + onOpen?.() + setIsPopoverOpen(true) + } + + const isDisabledApply = (): boolean => !!(isLoading || isDisabled) + + const button = ( + + ) + + return ( + e.stopPropagation()} + > + +
+ {children} +
+ + + handleDecline()} data-testid="cancel-btn"> + Cancel + + + + + + Save + + + +
+
+ ) +} + +export default PopoverItemEditor diff --git a/redisinsight/ui/src/components/popover-item-editor/index.ts b/redisinsight/ui/src/components/popover-item-editor/index.ts new file mode 100644 index 0000000000..e23e3abb72 --- /dev/null +++ b/redisinsight/ui/src/components/popover-item-editor/index.ts @@ -0,0 +1,3 @@ +import PopoverItemEditor from './PopoverItemEditor' + +export default PopoverItemEditor diff --git a/redisinsight/ui/src/components/popover-item-editor/styles.module.scss b/redisinsight/ui/src/components/popover-item-editor/styles.module.scss new file mode 100644 index 0000000000..761b0a4e2f --- /dev/null +++ b/redisinsight/ui/src/components/popover-item-editor/styles.module.scss @@ -0,0 +1,7 @@ +.content { + +} + +.footer { + margin-top: 6px !important; +} diff --git a/redisinsight/ui/src/components/pub-sub-config/PubSubConfig.spec.tsx b/redisinsight/ui/src/components/pub-sub-config/PubSubConfig.spec.tsx new file mode 100644 index 0000000000..842e35ec5c --- /dev/null +++ b/redisinsight/ui/src/components/pub-sub-config/PubSubConfig.spec.tsx @@ -0,0 +1,108 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { cloneDeep } from 'lodash' +import React from 'react' +import MockedSocket from 'socket.io-mock' +import socketIO from 'socket.io-client' +import { PubSubEvent, SubscriptionType } from 'uiSrc/constants/pubSub' +import { + disconnectPubSub, + pubSubSelector, + setLoading, + setPubSubConnected +} from 'uiSrc/slices/pubsub/pubsub' +import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' +import { SocketEvent } from 'uiSrc/constants' +import PubSubConfig from './PubSubConfig' + +let store: typeof mockedStore +let socket: typeof MockedSocket +beforeEach(() => { + cleanup() + socket = new MockedSocket() + socketIO.mockReturnValue(socket) + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('socket.io-client') + +jest.mock('uiSrc/slices/pubsub/pubsub', () => ({ + ...jest.requireActual('uiSrc/slices/pubsub/pubsub'), + pubSubSelector: jest.fn().mockReturnValue({ + isConnected: false, + isSubscribed: false, + isSubscribeTriggered: false + }), +})) + +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), + connectedInstanceSelector: jest.fn().mockReturnValue({ + id: '1' + }), +})) + +const subscriptionsMock = [{ channel: 'p*', type: SubscriptionType.PSubscribe }] + +describe('PubSubConfig', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should connect socket', () => { + const pubSubSelectorMock = jest.fn().mockReturnValue({ + isSubscribeTriggered: true, + }) + pubSubSelector.mockImplementation(pubSubSelectorMock) + + render() + + socket.socketClient.emit(SocketEvent.Connect) + + const afterRenderActions = [ + setPubSubConnected(true), + setLoading(true) + ] + expect(store.getActions()).toEqual([...afterRenderActions]) + }) + + it('should emit subscribe on channel', () => { + const pubSubSelectorMock = jest.fn().mockReturnValue({ + isSubscribeTriggered: true, + subscriptions: subscriptionsMock + }) + pubSubSelector.mockImplementation(pubSubSelectorMock) + + render() + + socket.on(PubSubEvent.Subscribe, (data: any) => { + expect(data).toEqual(subscriptionsMock) + }) + + socket.socketClient.emit(SocketEvent.Connect) + socket.socketClient.emit(PubSubEvent.Subscribe, subscriptionsMock) + }) + + it('monitor should catch disconnect', () => { + const pubSubSelectorMock = jest.fn().mockReturnValue({ + isSubscribeTriggered: true, + isConnected: true, + isSubscribed: true, + }) + pubSubSelector.mockImplementation(pubSubSelectorMock) + + const { unmount } = render() + + socket.socketClient.emit(SocketEvent.Connect) + socket.socketClient.emit(SocketEvent.Disconnect) + + const afterRenderActions = [ + setPubSubConnected(true), + setLoading(true), + disconnectPubSub(), + ] + expect(store.getActions()).toEqual([...afterRenderActions]) + + unmount() + }) +}) diff --git a/redisinsight/ui/src/components/pub-sub-config/PubSubConfig.tsx b/redisinsight/ui/src/components/pub-sub-config/PubSubConfig.tsx new file mode 100644 index 0000000000..f95b485e75 --- /dev/null +++ b/redisinsight/ui/src/components/pub-sub-config/PubSubConfig.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { io, Socket } from 'socket.io-client' + +import { SocketEvent } from 'uiSrc/constants' +import { PubSubEvent } from 'uiSrc/constants/pubSub' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { PubSubSubscription } from 'uiSrc/slices/interfaces/pubsub' +import { + concatPubSubMessages, + disconnectPubSub, + pubSubSelector, + setIsPubSubSubscribed, + setIsPubSubUnSubscribed, + setLoading, + setPubSubConnected, +} from 'uiSrc/slices/pubsub/pubsub' +import { getBaseApiUrl, Nullable } from 'uiSrc/utils' + +interface IProps { + retryDelay?: number; +} + +const PubSubConfig = ({ retryDelay = 5000 } : IProps) => { + const { id: instanceId = '' } = useSelector(connectedInstanceSelector) + const { isSubscribeTriggered, isConnected, subscriptions } = useSelector(pubSubSelector) + const socketRef = useRef>(null) + + const dispatch = useDispatch() + + useEffect(() => { + if (!isSubscribeTriggered || !instanceId || socketRef.current?.connected) { + return + } + let retryTimer: NodeJS.Timer + + socketRef.current = io(`${getBaseApiUrl()}/pub-sub`, { + forceNew: true, + query: { instanceId }, + rejectUnauthorized: false, + }) + + socketRef.current.on(SocketEvent.Connect, () => { + // Trigger Monitor event + clearTimeout(retryTimer) + dispatch(setPubSubConnected(true)) + subscribeForChannels() + }) + + // Catch connect error + socketRef.current?.on(SocketEvent.ConnectionError, (error) => {}) + + // Catch exceptions + socketRef.current?.on(PubSubEvent.Exception, (error) => { + handleDisconnect() + }) + + // Catch disconnect + socketRef.current?.on(SocketEvent.Disconnect, () => { + if (retryDelay) { + retryTimer = setTimeout(handleDisconnect, retryDelay) + } else { + handleDisconnect() + } + }) + }, [instanceId, isSubscribeTriggered]) + + useEffect(() => { + if (!socketRef.current?.connected) { + return + } + + if (!isSubscribeTriggered) { + unSubscribeFromChannels() + return + } + + subscribeForChannels() + }, [isSubscribeTriggered]) + + const subscribeForChannels = () => { + dispatch(setLoading(true)) + socketRef.current?.emit( + PubSubEvent.Subscribe, + { subscriptions }, + onChannelsSubscribe + ) + } + + const unSubscribeFromChannels = () => { + dispatch(setLoading(true)) + socketRef.current?.emit( + PubSubEvent.Unsubscribe, + { subscriptions }, + onChannelsUnSubscribe + ) + } + + const onChannelsSubscribe = () => { + dispatch(setLoading(false)) + dispatch(setIsPubSubSubscribed()) + subscriptions.forEach(({ channel, type }: PubSubSubscription) => { + const subscription = `${type}:${channel}` + const isListenerExist = !!socketRef.current?.listeners(subscription).length + + if (!isListenerExist) { + socketRef.current?.on(subscription, (data) => { + dispatch(concatPubSubMessages(data)) + }) + } + }) + } + + const onChannelsUnSubscribe = () => { + dispatch(setIsPubSubUnSubscribed()) + dispatch(setLoading(false)) + + subscriptions.forEach(({ channel, type }: PubSubSubscription) => { + socketRef.current?.removeListener(`${type}:${channel}`) + }) + } + + useEffect(() => { + if (!isConnected && socketRef.current?.connected) { + handleDisconnect() + } + }, [isConnected]) + + const handleDisconnect = () => { + dispatch(disconnectPubSub()) + socketRef.current?.removeAllListeners() + socketRef.current?.disconnect() + } + + return null +} + +export default PubSubConfig diff --git a/redisinsight/ui/src/components/pub-sub-config/index.ts b/redisinsight/ui/src/components/pub-sub-config/index.ts new file mode 100644 index 0000000000..76a28499f0 --- /dev/null +++ b/redisinsight/ui/src/components/pub-sub-config/index.ts @@ -0,0 +1,3 @@ +import PubSubConfig from './PubSubConfig' + +export default PubSubConfig diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx b/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx index fad7c1c39a..d4375cf20d 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx +++ b/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx @@ -238,6 +238,7 @@ const QueryCardCliPlugin = (props: Props) => { className={cx('pluginIframe', styles.pluginIframe, { [styles.hidden]: !currentPlugin || !isPluginLoaded || !!error })} title={id} ref={pluginIframeRef} + src="about:blank" referrerPolicy="no-referrer" sandbox="allow-same-origin allow-scripts" data-testid="pluginIframe" 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 e79e0b335d..513f4ac7f6 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 @@ -1,14 +1,16 @@ import React from 'react' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' import { checkUnsupportedCommand, checkUnsupportedModuleCommand, cliParseTextResponse, + CliPrefix, getCommandRepeat, isRepeatCountCorrect } from 'uiSrc/utils' import { cliTexts, SelectCommand } from 'uiSrc/constants/cliOutput' -import { CommandMonitor } from 'uiSrc/constants' +import { CommandMonitor, CommandPSubscribe, Pages } from 'uiSrc/constants' import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' import { RedisDefaultModules } from 'uiSrc/slices/interfaces' import { RSNotLoadedContent } from 'uiSrc/pages/workbench/constants' @@ -16,20 +18,23 @@ import { RSNotLoadedContent } from 'uiSrc/pages/workbench/constants' import { cliSettingsSelector } from 'uiSrc/slices/cli/cli-settings' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import ModuleNotLoaded from 'uiSrc/pages/workbench/components/module-not-loaded' +import { showMonitor } from 'uiSrc/slices/cli/monitor' const CommonErrorResponse = (command = '', result?: any) => { + const { instanceId = '' } = useParams<{ instanceId: string }>() const { unsupportedCommands: cliUnsupportedCommands, blockingCommands } = useSelector(cliSettingsSelector) const { modules } = useSelector(connectedInstanceSelector) + const dispatch = useDispatch() const unsupportedCommands = [SelectCommand.toLowerCase(), ...cliUnsupportedCommands, ...blockingCommands] const [commandLine, countRepeat] = getCommandRepeat(command) - // Flow if monitor command was executed + // Flow if MONITOR command was executed if (checkUnsupportedCommand([CommandMonitor.toLowerCase()], commandLine)) { - return cliParseTextResponse( - cliTexts.MONITOR_COMMAND, - commandLine, - CommandExecutionStatus.Fail, - ) + return cliTexts.MONITOR_COMMAND(() => { dispatch(showMonitor()) }) + } + // Flow if PSUBSCRIBE command was executed + if (checkUnsupportedCommand([CommandPSubscribe.toLowerCase()], commandLine)) { + return cliTexts.PSUBSCRIBE_COMMAND(Pages.pubSub(instanceId)) } const unsupportedCommand = checkUnsupportedCommand(unsupportedCommands, commandLine) @@ -39,6 +44,7 @@ const CommonErrorResponse = (command = '', result?: any) => { cliTexts.UNABLE_TO_DECRYPT, '', CommandExecutionStatus.Fail, + CliPrefix.QueryCard, ) } @@ -47,6 +53,7 @@ const CommonErrorResponse = (command = '', result?: any) => { cliTexts.REPEAT_COUNT_INVALID, commandLine, CommandExecutionStatus.Fail, + CliPrefix.QueryCard, ) } diff --git a/redisinsight/ui/src/components/query/Query/Query.tsx b/redisinsight/ui/src/components/query/Query/Query.tsx index c2ad759cd5..6112cdc6dc 100644 --- a/redisinsight/ui/src/components/query/Query/Query.tsx +++ b/redisinsight/ui/src/components/query/Query/Query.tsx @@ -6,6 +6,7 @@ import cx from 'classnames' import { EuiButtonIcon, EuiText, EuiToolTip } from '@elastic/eui' import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api' import MonacoEditor, { monaco } from 'react-monaco-editor' +import { useParams } from 'react-router-dom' import { Theme, @@ -34,6 +35,7 @@ import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' import { IEditorMount, ISnippetController } from 'uiSrc/pages/workbench/interfaces' import { CommandExecutionUI } from 'uiSrc/slices/interfaces' import { darkTheme, lightTheme } from 'uiSrc/constants/monaco/cypher' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { workbenchResultsSelector } from 'uiSrc/slices/workbench/wb-results' import DedicatedEditor from 'uiSrc/components/query/DedicatedEditor/DedicatedEditor' @@ -77,6 +79,8 @@ const Query = (props: Props) => { const { theme } = useContext(ThemeContext) const monacoObjects = useRef>(null) + const { instanceId = '' } = useParams<{ instanceId: string }>() + let disposeCompletionItemProvider = () => {} let disposeSignatureHelpProvider = () => {} @@ -125,7 +129,7 @@ const Query = (props: Props) => { const triggerUpdateCursorPosition = (editor: monacoEditor.editor.IStandaloneCodeEditor) => { const position = editor.getPosition() isDedicatedEditorOpenRef.current = false - editor.trigger('mouse', '_moveTo', { position: { lineNumber: 1, column: 1 }}) + editor.trigger('mouse', '_moveTo', { position: { lineNumber: 1, column: 1 } }) editor.trigger('mouse', '_moveTo', { position }) editor.focus() } @@ -137,6 +141,13 @@ const Query = (props: Props) => { setIsDedicatedEditorOpen(true) editor.updateOptions({ readOnly: true }) hideSyntaxWidget(editor) + sendEventTelemetry({ + event: TelemetryEvent.WORKBENCH_NON_REDIS_EDITOR_OPENED, + eventData: { + databaseId: instanceId, + lang: syntaxCommand.current.lang, + } + }) } const onChange = (value: string = '') => { @@ -305,6 +316,14 @@ const Query = (props: Props) => { editor.updateOptions({ readOnly: false }) triggerUpdateCursorPosition(editor) + + sendEventTelemetry({ + event: TelemetryEvent.WORKBENCH_NON_REDIS_EDITOR_CANCELLED, + eventData: { + databaseId: instanceId, + lang: syntaxCommand.current.lang, + } + }) } const updateArgFromDedicatedEditor = (value: string = '') => { @@ -333,6 +352,13 @@ const Query = (props: Props) => { ]) setIsDedicatedEditorOpen(false) triggerUpdateCursorPosition(editor) + sendEventTelemetry({ + event: TelemetryEvent.WORKBENCH_NON_REDIS_EDITOR_SAVED, + eventData: { + databaseId: instanceId, + lang: syntaxCommand.current.lang, + } + }) } const editorDidMount = ( diff --git a/redisinsight/ui/src/components/range-filter/RangeFilter.tsx b/redisinsight/ui/src/components/range-filter/RangeFilter.tsx index f8503419e4..25ccbb4ab5 100644 --- a/redisinsight/ui/src/components/range-filter/RangeFilter.tsx +++ b/redisinsight/ui/src/components/range-filter/RangeFilter.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useState, useEffect, useRef } from 'react' import cx from 'classnames' -import { getFormatTime, } from 'uiSrc/utils/streamUtils' +import { getFormatTime } from 'uiSrc/utils/streamUtils' import styles from './styles.module.scss' @@ -12,6 +12,7 @@ export interface Props { min: number start: number end: number + disabled?: boolean handleChangeStart: (value: number, shouldSentEventTelemetry: boolean) => void handleChangeEnd: (value: number, shouldSentEventTelemetry: boolean) => void handleUpdateRangeMax: (value: number) => void @@ -33,6 +34,7 @@ const RangeFilter = (props: Props) => { min, start, end, + disabled = false, handleChangeStart, handleChangeEnd, handleUpdateRangeMax, @@ -152,6 +154,7 @@ const RangeFilter = (props: Props) => { max={max} value={startVal} ref={minValRef} + disabled={disabled} onChange={onChangeStart} onMouseUp={onMouseUpStart} className={cx(styles.thumb, styles.thumbZindex3)} @@ -163,6 +166,7 @@ const RangeFilter = (props: Props) => { max={max} value={endVal} ref={maxValRef} + disabled={disabled} onChange={onChangeEnd} onMouseUp={onMouseUpEnd} className={cx(styles.thumb, styles.thumbZindex4)} @@ -170,11 +174,21 @@ const RangeFilter = (props: Props) => { />
-
+
@@ -183,7 +197,8 @@ const RangeFilter = (props: Props) => {
(max - min) / 2 + [styles.rightPosition]: max - endVal > (max - min) / 2, + [styles.disabled]: disabled }) } > diff --git a/redisinsight/ui/src/components/range-filter/index.ts b/redisinsight/ui/src/components/range-filter/index.ts new file mode 100644 index 0000000000..c754538242 --- /dev/null +++ b/redisinsight/ui/src/components/range-filter/index.ts @@ -0,0 +1,3 @@ +import RangeFilter from './RangeFilter' + +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 index 88babe2ced..67262bbdab 100644 --- a/redisinsight/ui/src/components/range-filter/styles.module.scss +++ b/redisinsight/ui/src/components/range-filter/styles.module.scss @@ -63,12 +63,11 @@ } } -.rangeWrapper:hover .sliderRange { +.rangeWrapper:hover .sliderRange:not(.disabled) { height: 5px; transform: translateY(0px); &:before { - width: 2px; height: 12px; top: -7px; @@ -93,11 +92,11 @@ margin-top: -25px; } -.rangeWrapper:hover .sliderLeftValue { +.rangeWrapper:hover .sliderLeftValue:not(.disabled) { margin-top: -23px; } -.rangeWrapper:hover .sliderRightValue { +.rangeWrapper:hover .sliderRightValue:not(.disabled) { margin-top: 10px; } @@ -132,12 +131,6 @@ outline: none; } -@-moz-document url-prefix() { - .thumb { - margin-top: 0.33px; - } -} - .thumbZindex3 { z-index: 3; } @@ -158,12 +151,16 @@ background: transparent; } +.thumb::-moz-range-thumb:disabled { + cursor: auto; +} + .thumbZindex3::-moz-range-thumb { - transform: translateY(-4px); + transform: translate(-18px, -4px); } .thumbZindex4::-moz-range-thumb { - transform: translateY(8px) rotate(180deg); + transform: translate(-20px, 8px) rotate(180deg); } input[type='range']::-webkit-slider-thumb { @@ -178,10 +175,14 @@ input[type='range']::-webkit-slider-thumb { background: transparent; } +input[type='range'][disabled]::-webkit-slider-thumb { + cursor: auto; +} + input[type='range']:first-child::-webkit-slider-thumb { - transform: translateY(-4px); + transform: translate(-18px, -4px); } input[type='range']:last-of-type::-webkit-slider-thumb { - transform: translateY(8px) rotate(180deg); + transform: translate(-20px, 8px) rotate(180deg); } diff --git a/redisinsight/ui/src/components/virtual-table/VirtualTable.tsx b/redisinsight/ui/src/components/virtual-table/VirtualTable.tsx index 5a359b3df3..7f16552afe 100644 --- a/redisinsight/ui/src/components/virtual-table/VirtualTable.tsx +++ b/redisinsight/ui/src/components/virtual-table/VirtualTable.tsx @@ -188,6 +188,7 @@ const VirtualTable = (props: IProps) => { styles.headerButton, isColumnSorted ? styles.headerButtonSorted : null, )} + data-testid="header-sorting-button" > ( + + {'Use '} + + Pub/Sub + + {' to see the messages published to all channels in your database.'} + + ), + PSUBSCRIBE_COMMAND_CLI: (path: string = '') => ( + [ + cliTexts.PSUBSCRIBE_COMMAND(path), + '\n', + ] + ), + MONITOR_COMMAND: (onClick: () => void) => ( + + {'Use '} + + Profiler + + {' tool to see all the requests processed by the server.'} + + ), + MONITOR_COMMAND_CLI: (onClick: () => void) => ( + [ + cliTexts.MONITOR_COMMAND(onClick), + '\n', + ] + ), CLI_ERROR_MESSAGE: (message: string) => ( [ '\n', - + {message} , '\n\n', diff --git a/redisinsight/ui/src/constants/commands.ts b/redisinsight/ui/src/constants/commands.ts index 74a9fd2ca5..931f1f1f2c 100644 --- a/redisinsight/ui/src/constants/commands.ts +++ b/redisinsight/ui/src/constants/commands.ts @@ -82,6 +82,7 @@ export enum CommandPrefix { } export const CommandMonitor = 'MONITOR' +export const CommandPSubscribe = 'PSUBSCRIBE' export enum CommandRediSearch { Search = 'FT.SEARCH', diff --git a/redisinsight/ui/src/constants/commandsVersions.ts b/redisinsight/ui/src/constants/commandsVersions.ts index ca746c7d3f..0b6fadc55c 100644 --- a/redisinsight/ui/src/constants/commandsVersions.ts +++ b/redisinsight/ui/src/constants/commandsVersions.ts @@ -5,4 +5,7 @@ export const CommandsVersions = { FILTER_PER_KEY_TYPES: { since: '6.0', }, + SPUBLISH_NOT_SUPPORTED: { + since: '7.0', + }, } diff --git a/redisinsight/ui/src/constants/index.ts b/redisinsight/ui/src/constants/index.ts index 5a1c3a006b..466cb1a059 100644 --- a/redisinsight/ui/src/constants/index.ts +++ b/redisinsight/ui/src/constants/index.ts @@ -21,4 +21,5 @@ export * from './mocks/mock-tutorials' export * from './socketErrors' export * from './browser' export * from './durationUnits' +export * from './streamViews' export { ApiEndpoints, BrowserStorageItem, ApiStatusCode, apiErrors } diff --git a/redisinsight/ui/src/constants/keys.ts b/redisinsight/ui/src/constants/keys.ts index 98a946e04b..1f9a927a45 100644 --- a/redisinsight/ui/src/constants/keys.ts +++ b/redisinsight/ui/src/constants/keys.ts @@ -1,3 +1,4 @@ +import { StreamViewType } from 'uiSrc/slices/interfaces/stream' import { CommandGroup } from './commands' export enum KeyTypes { @@ -114,11 +115,27 @@ export const KEY_TYPES_ACTIONS: KeyTypesActions = Object.freeze({ name: 'Edit Value', }, }, - [KeyTypes.ReJSON]: {}, - [KeyTypes.Stream]: { - addItems: { - name: 'New Entry', - }, + [KeyTypes.ReJSON]: {} +}) + +export const STREAM_ADD_GROUP_VIEW_TYPES = [ + StreamViewType.Groups, + StreamViewType.Consumers, + StreamViewType.Messages +] + +export const STREAM_ADD_ACTION = Object.freeze({ + [StreamViewType.Data]: { + name: 'New Entry' + }, + [StreamViewType.Groups]: { + name: 'New Group' + }, + [StreamViewType.Consumers]: { + name: 'New Group' + }, + [StreamViewType.Messages]: { + name: 'New Group' } }) diff --git a/redisinsight/ui/src/constants/pages.ts b/redisinsight/ui/src/constants/pages.ts index 946b8b1767..2302822b24 100644 --- a/redisinsight/ui/src/constants/pages.ts +++ b/redisinsight/ui/src/constants/pages.ts @@ -1,4 +1,4 @@ -import React from 'react'; +import React from 'react' export interface IRoute { path: any; @@ -13,7 +13,8 @@ export interface IRoute { export enum PageNames { workbench = 'workbench', browser = 'browser', - slowLog = 'slowlog' + slowLog = 'slowlog', + pubSub = 'pub-sub', } const redisCloud = '/redis-cloud' @@ -34,4 +35,5 @@ export const Pages = { browser: (instanceId: string) => `/${instanceId}/${PageNames.browser}`, workbench: (instanceId: string) => `/${instanceId}/${PageNames.workbench}`, slowLog: (instanceId: string) => `/${instanceId}/${PageNames.slowLog}`, + pubSub: (instanceId: string) => `/${instanceId}/${PageNames.pubSub}`, } diff --git a/redisinsight/ui/src/constants/pubSub.ts b/redisinsight/ui/src/constants/pubSub.ts new file mode 100644 index 0000000000..3984871e1d --- /dev/null +++ b/redisinsight/ui/src/constants/pubSub.ts @@ -0,0 +1,11 @@ +export enum SubscriptionType { + Subscribe = 's', + PSubscribe = 'p', + SSubscribe = 'ss', +} + +export enum PubSubEvent { + Subscribe = 'subscribe', + Unsubscribe = 'unsubscribe', + Exception = 'exception', +} diff --git a/redisinsight/ui/src/constants/streamViews.ts b/redisinsight/ui/src/constants/streamViews.ts new file mode 100644 index 0000000000..922182f73d --- /dev/null +++ b/redisinsight/ui/src/constants/streamViews.ts @@ -0,0 +1,8 @@ +import { StreamViewType } from 'uiSrc/slices/interfaces/stream' + +export const StreamViews = Object.freeze({ + [StreamViewType.Data]: 'entries', + [StreamViewType.Groups]: 'consumer_groups', + [StreamViewType.Consumers]: 'consumers', + [StreamViewType.Messages]: 'pending_messages_list' +}) diff --git a/redisinsight/ui/src/constants/texts.tsx b/redisinsight/ui/src/constants/texts.tsx index 9fabc85d26..afc5413a39 100644 --- a/redisinsight/ui/src/constants/texts.tsx +++ b/redisinsight/ui/src/constants/texts.tsx @@ -25,3 +25,12 @@ export const ScanNoResultsFoundText = ( ) + +export const lastDeliveredIDTooltipText = ( + <> + Specify the ID of the last delivered entry in the stream from the new group's perspective. + + Otherwise, $ represents the ID of the last entry in the stream,  + 0 fetches the entire stream from the beginning. + +) diff --git a/redisinsight/ui/src/constants/workbenchResults.ts b/redisinsight/ui/src/constants/workbenchResults.ts index b0e897050e..5dd9d3bcd0 100644 --- a/redisinsight/ui/src/constants/workbenchResults.ts +++ b/redisinsight/ui/src/constants/workbenchResults.ts @@ -1 +1 @@ -export const bulkReplyCommands = ['LOLWUT', 'INFO', 'CLIENT', 'CLUSTER', 'MEMORY', 'MONITOR'] +export const bulkReplyCommands = ['LOLWUT', 'INFO', 'CLIENT', 'CLUSTER', 'MEMORY', 'MONITOR', 'PSUBSCRIBE'] diff --git a/redisinsight/ui/src/packages/redisgraph/src/Graph.tsx b/redisinsight/ui/src/packages/redisgraph/src/Graph.tsx index 4dd68eb185..87d9f285ea 100644 --- a/redisinsight/ui/src/packages/redisgraph/src/Graph.tsx +++ b/redisinsight/ui/src/packages/redisgraph/src/Graph.tsx @@ -1,9 +1,10 @@ -import { useEffect, useRef, useState, useMemo } from 'react' +import React, { useEffect, useRef, useState, useMemo } from 'react' import * as d3 from 'd3' import { executeRedisCommand } from 'redisinsight-plugin-sdk' import { EuiButtonIcon, EuiToolTip, + EuiSwitch, } from '@elastic/eui' import Graphd3, { IGraphD3 } from './graphd3' import { responseParser } from './parser' @@ -49,6 +50,7 @@ export default function Graph(props: { graphKey: string, data: any[] }) { const [container, setContainer] = useState(null) const [selectedEntity, setSelectedEntity] = useState(null) const [start, setStart] = useState(false) + const [showAutomaticEdges, setShowAutomaticEdges] = useState(false); const parsedResponse = responseParser(props.data) let nodeIds = new Set(parsedResponse.nodes.map(n => n.id)) @@ -135,7 +137,7 @@ export default function Graph(props: { graphKey: string, data: any[] }) { .filter(e => nodeIds.has(e.source) && nodeIds.has(e.target) && !edgeIds.has(e.id)) .map(e => { newEdgeTypes[e.type] = (newEdgeTypes[e.type] + 1 || 1) - return ({ ...e, startNode: e.source, endNode: e.target }) + return ({ ...e, startNode: e.source, endNode: e.target, fetchedAutomatically: true }) }) setGraphData({ @@ -250,6 +252,18 @@ export default function Graph(props: { graphKey: string, data: any[] }) { return (
+
+ + { + container.toggleShowAutomaticEdges() + setShowAutomaticEdges(!showAutomaticEdges) + }} + /> + +
{ @@ -259,14 +273,14 @@ export default function Graph(props: { graphKey: string, data: any[] }) { Object.keys(nodeLabels).map((item, i) => (
{item}
)) } -
+
) } { @@ -295,7 +309,7 @@ export default function Graph(props: { graphKey: string, data: any[] }) { selectedEntity.type === EntityType.Node ?
{selectedEntity.property}
: -
{selectedEntity.property}
+
{selectedEntity.property}
} setSelectedEntity(null)} display="empty" iconType="cross" aria-label="Close" />
@@ -303,9 +317,7 @@ export default function Graph(props: { graphKey: string, data: any[] }) { { Object.keys(selectedEntity.props).map(k => [k, selectedEntity.props[k]]).reduce( (a, b) => a.concat(b), [] - ).map(k => -
{k}
- ) + ).map(k =>
{k}
) }
diff --git a/redisinsight/ui/src/packages/redisgraph/src/graphd3.ts b/redisinsight/ui/src/packages/redisgraph/src/graphd3.ts index cfefcd0b64..d806b5cd55 100644 --- a/redisinsight/ui/src/packages/redisgraph/src/graphd3.ts +++ b/redisinsight/ui/src/packages/redisgraph/src/graphd3.ts @@ -571,6 +571,10 @@ interface INode extends d3.SimulationNodeDatum { properties: { [key: string]: string | number | object } labels: string[] color: string + angleX: number + angleY: number + links: string[] + targetLabels: {[label: string]: number} } interface IRelationship extends d3.SimulationLinkDatum{ @@ -592,6 +596,7 @@ interface IRelationship extends d3.SimulationLinkDatum{ shaftLength: number midShaftPoint: Point } + fetchedAutomatically?: boolean } interface IGraph { @@ -615,11 +620,13 @@ export interface IGraphD3 { updateWithD3Data: (d3Data: any) => void updateWithGraphData: (graphData: any) => void zoomFuncs: IZoomFuncs + toggleShowAutomaticEdges: () => void } function GraphD3(_selector: HTMLDivElement, _options: any): IGraphD3 { let info: any let nodes: INode[] + let shouldShowAutomaticEdges = false; let relationship: d3.Selection let labelCounter = 0; let labels: { [key: string]: number } = { } @@ -816,6 +823,10 @@ function GraphD3(_selector: HTMLDivElement, _options: any): IGraphD3 { if (typeof options.onNodeClick === 'function') { options.onNodeClick(this, d, event) } + + if (info) { + updateInfo(d) + } }) .on('dblclick', function onNodeDoubleClick(event, d) { stickNode(this, event, d) @@ -843,10 +854,6 @@ function GraphD3(_selector: HTMLDivElement, _options: any): IGraphD3 { .call(zoom.translateTo as any, d.x, d.y), 10) }) .on('mouseenter', function onNodeMouseEnter(event, d) { - if (info) { - updateInfo(d) - } - if (typeof options.onNodeMouseEnter === 'function') { options.onNodeMouseEnter(this, d, event) } @@ -893,6 +900,10 @@ function GraphD3(_selector: HTMLDivElement, _options: any): IGraphD3 { if (typeof options.onNodeInfoClick === 'function') { options.onNodeInfoClick(this, d, event) } + + if (info) { + updateInfo(d) + } }) g.append('rect') @@ -979,13 +990,13 @@ function GraphD3(_selector: HTMLDivElement, _options: any): IGraphD3 { function appendRelationship() { return relationship.enter() .append('g') - .attr('class', 'relationship') + .attr('class', r => `relationship relationship-${r.id}`) .on('dblclick', function onRelationshipDoubleClick(event, d) { if (typeof options.onRelationshipDoubleClick === 'function') { options.onRelationshipDoubleClick(this, d, event) } }) - .on('mouseenter', (event, d) => { + .on('click', (event, d) => { if (info) { updateInfo(d) } @@ -1042,7 +1053,6 @@ function GraphD3(_selector: HTMLDivElement, _options: any): IGraphD3 { function updateRelationships(r: IRelationship) { Array.prototype.push.apply(relationships, r) - let a = svgRelationships.selectAll('.relationship') relationship = svgRelationships .selectAll('.relationship') @@ -1067,14 +1077,31 @@ function GraphD3(_selector: HTMLDivElement, _options: any): IGraphD3 { n = n.filter(k => !nodeIds.includes(k.id)) let edgeIds = relationships.map(e => e.id) + const previousEdges = [...r] r = r.filter(k => !edgeIds.includes(k.id)) + if (relationship !== undefined) { + relationship.each(r => { + // If an edge is being fetchedAutomatically and is now added + // in new data, mark fetchedAutomatically to false. + if (r.fetchedAutomatically && previousEdges.map(k => k.id).includes(r.id)) { + r.fetchedAutomatically = false; + } + }) + } + updateRelationships(r) updateNodes(n) simulation.nodes(nodes) simulation.force('link', d3.forceLink(relationships).id((d: IRelationship) => d.id)) + // Every time the function is run, do check whether automatically fetched edges must be rendered. + d3.selectAll('.relationship').each((r: IRelationship) => { + if (!shouldShowAutomaticEdges && r.fetchedAutomatically) { + d3.selectAll(`.relationship-${r.id}`).remove() + } + }) } function graphDataToD3Data(data) { @@ -1639,12 +1666,20 @@ function GraphD3(_selector: HTMLDivElement, _options: any): IGraphD3 { resize() + function toggleShowAutomaticEdges() { + // Simply re-run the function. `updateNodesAndRelationships` internally checks for `shouldShowAutomaticEdges` prop to render edges that were fetched automatically. + shouldShowAutomaticEdges = !shouldShowAutomaticEdges; + updateNodesAndRelationships([], []) + simulation.restart() + } + return { graphDataToD3Data, size, updateWithD3Data, updateWithGraphData, zoomFuncs, + toggleShowAutomaticEdges, } } diff --git a/redisinsight/ui/src/packages/redisgraph/src/styles/styles.less b/redisinsight/ui/src/packages/redisgraph/src/styles/styles.less index a10439250c..1a3c1168e5 100644 --- a/redisinsight/ui/src/packages/redisgraph/src/styles/styles.less +++ b/redisinsight/ui/src/packages/redisgraph/src/styles/styles.less @@ -10,12 +10,14 @@ --info-background: #20222B; --info-color: white; --svg-background: #010101; + --tooltip-background: #3E4B5E; } .theme_LIGHT { --info-background: white; --info-color: black; --svg-background: #FFFFFF; + --tooltip-background: white; } @@ -251,3 +253,27 @@ padding: 12px !important; font-family: monospace !important; } + +.euiToolTip { + color: var(--info-color) !important; + background-color: var(--tooltip-background) !important; + font-size: 12px !important; +} + +.euiToolTip__arrow { + background-color: var(--tooltip-background) !important; +} + + +.automatic-edges-switch { + border-radius: 4px; + right: 4px; + position: absolute; + display: flex; + flex-direction: row; + color: var(--info-color); + margin-top: 12px !important; + margin-left: 24px !important; + font-size: 12px; + line-height: 18px; +} diff --git a/redisinsight/ui/src/packages/redistimeseries-app/package.json b/redisinsight/ui/src/packages/redistimeseries-app/package.json index 5de00a5992..8ba940a200 100644 --- a/redisinsight/ui/src/packages/redistimeseries-app/package.json +++ b/redisinsight/ui/src/packages/redistimeseries-app/package.json @@ -15,11 +15,10 @@ "version": "0.0.1", "scripts": { "start": "cross-env NODE_ENV=development parcel serve src/index.html", - "build": "rimraf dist .parcel-cache && cross-env NODE_ENV=production yarn build:js && yarn minify:js && yarn build:css && yarn build:assets", - "build:js": "parcel build src/main.tsx --dist-dir dist", - "build:css": "parcel build src/styles/styles.less --dist-dir dist", + "build": "rimraf dist .parcel-cache && cross-env NODE_ENV=production yarn build:js && yarn build:css && yarn build:assets", + "build:js": "esbuild src/main.tsx --bundle --minify --format=esm --outfile=dist/index.js", + "build:css": "lessc src/styles/styles.less dist/styles.css", "build:assets": "parcel build src/assets/**/* --dist-dir dist", - "minify:js": "terser --compress --mangle -- dist/main.js > dist/index.js && rimraf dist/main.js", "test": "jest" }, "targets": { @@ -53,10 +52,11 @@ "@types/plotly.js-dist-min": "^2.3.0", "concurrently": "^6.3.0", "cross-env": "^7.0.3", + "esbuild": "^0.14.41", "jest": "^27.5.1", + "less": "^4.1.2", "parcel": "^2.0.0", "rimraf": "^3.0.2", - "terser": "^5.9.0", "ts-jest": "^27.1.4" }, "dependencies": { diff --git a/redisinsight/ui/src/packages/redistimeseries-app/yarn.lock b/redisinsight/ui/src/packages/redistimeseries-app/yarn.lock index ff59f36b57..0f3c637a7f 100644 --- a/redisinsight/ui/src/packages/redistimeseries-app/yarn.lock +++ b/redisinsight/ui/src/packages/redistimeseries-app/yarn.lock @@ -2302,6 +2302,132 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" +esbuild-android-64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.41.tgz#34de1b983a81b22207db6faf937a600f15c5ca9b" + integrity sha512-byyo8LPOGHzAqxbwh2Q72d7L+rXXTsr/KALjsiCySrJ60CGMe80i3bwoQ+WODxsGaH08R//yg5oc7xHKgQz4uw== + +esbuild-android-arm64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.41.tgz#42ff3d409d4962342a67aef3d2caa9be502e3def" + integrity sha512-7koo9Dm/mwK4M8PGQX8JQRc4UQ4Wj7DT1nD4BQkVs2jxtHbYOlnsQH0fneKSXZVmnBIHYcntr/e1VU5gnYLvGQ== + +esbuild-darwin-64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.41.tgz#73d8789b59c6086cfeba9aa59f60cb12cfbe4439" + integrity sha512-kW8fC2auh9jjmBXudTmlMfbBCMYMuujhxG40CxMhKiQ8NLBK4RU9yUYY6ss7QJp24kVTtKd4IvfwOio9SE53MA== + +esbuild-darwin-arm64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.41.tgz#faf5eecd8e8a170aea741f0f0896e73f5b0e0716" + integrity sha512-cO0EPkiQt0bERH9sZFIoTywWfGhEpshdpvQpDfLh/ZJLeioQfaarM9YDrmID+f7k77djh0mdyfsC6XpS0HlLsw== + +esbuild-freebsd-64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.41.tgz#b44923dfdf84bb3ec3a84739817ea08e4b50c0fb" + integrity sha512-6tsMDK6b7czCOjsr68BgVogFXcTCWL3T7yFXRFuAmXwY9ybYgX8sybD7ztrRB03dLAPeMxHo+PzeMD6LdVrLdQ== + +esbuild-freebsd-arm64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.41.tgz#91dac0d5d5a3f9dc4a422628556bd64c98d62c1e" + integrity sha512-AQ2S/VCLKVBe/+HNiFLyp3w9i7AEtCOWEzKHSkfHk0VO5bPzHd7WJfWmj1Bxliu7vdPESbiDUTJIH3rDt4bzSA== + +esbuild-linux-32@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.41.tgz#6342094df325df46ad7a3d920af7af04c1a495a9" + integrity sha512-sb7Kah5Px6BNZ6gzm0nJLuDeAJKbIlaKIoI9zgZ5dFDxZSn91TMAHJz5W39YDJ8+ZaGJYIdqZSpDo+4G769mZw== + +esbuild-linux-64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.41.tgz#e7e193c229aa3988202adb92b8567ae504767b8e" + integrity sha512-PeI0bfbv+5ondZRhPRszptp3RQRRAPxpOB2CYDphKske5+UlCXPi4Af+T++OqhV5TEpymTfxJdJQ1sn1w32coA== + +esbuild-linux-arm64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.41.tgz#ca3d83b2a8ae1f160c05bdc08daa88f54279506c" + integrity sha512-aAhBX6kVG8hTVuANE90ORobioHdpZLzy8Fibf4XBuG4IuJfjgM5N4wFIq2Tpd+Ykit432PL/YOQhZ4W6nVc4cQ== + +esbuild-linux-arm@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.41.tgz#6f9b5ca9cbdb33b4751b658f7d64c0e907b64685" + integrity sha512-8DQ6Sv3XNwgu0cnPA3q+kJSqfOYLDqWzpW8dPF+/Or23bS/5EIT/CzN73uIhR8A3AokXIczn88VKti7Xtv+V2g== + +esbuild-linux-mips64le@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.41.tgz#e21b8dc51d6d3d78e4e3de0ccae20a6b0aae28a1" + integrity sha512-88xo4FRYQ2laMJnrqZu8j5q531XT/odZnhO5NLWO/NdweIdT8F+QL0fNIBIf+nVkC1d0Psgmt+g35GAODMDl8g== + +esbuild-linux-ppc64le@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.41.tgz#bd70331c3df64ed3c74947d8ec5354a7cb11abae" + integrity sha512-kJ0r/Cg3LzFzHhbBsvqi/hDPGKMGzFiPGOmUvqTkfVXhRUQtOMkXkyKdP7OEMRb8ctPtnptsZOOXPHRdU0NdJQ== + +esbuild-linux-riscv64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.41.tgz#2272aff043bf1fe7b397df25698c8f79605b9b71" + integrity sha512-ZJ7d/qFRx14J3aP75ccrFSZyuYZ1hu8IVfwVqyQg4jQFgNME2FMz7pZMskBJ0fSW8QcYUnN3RubFXWijyjKUug== + +esbuild-linux-s390x@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.41.tgz#01916946a31c1aeb2de5b1b4f21432843fb88f91" + integrity sha512-xeWAEZt1jAfYkYuyIUuHKpH/oj7O862Je5HTH9E+4sEfoOnZaAmFrisbXjGDKXjMRKYscFlM8wXdNBmiqQlT8g== + +esbuild-netbsd-64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.41.tgz#0a9d3e5c73d362f91178ad21a7e020d246a039e7" + integrity sha512-X/UE3Asqy594/atYi/STgYtaMQBJwtZKF0KFFdJTkwb6rtaoHCM1o482iHibgnSK7CicuRhyTZ+cNx4OFqRQAg== + +esbuild-openbsd-64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.41.tgz#32c0ec0678a206b0e75340f3a3303f38cb5b3408" + integrity sha512-6m+1dtdO+4KaU3R0UTT82hxWxWpFCjgSHhQl/PKtMmq+CvvxRQDcTwujLC843M7ChGVWNM2q1s6YCwoA0WQ9kw== + +esbuild-sunos-64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.41.tgz#2f6f26fbc88778382d61145d2d6ec1c6ad5d47c2" + integrity sha512-p96tTTcE8/WY7A4Udh+fxVUTGL8rIXOpyxyhZiXug+f7DGbjE24PbewqgIBRSDyM7xRUty+1RzqyJz73YIV6yg== + +esbuild-windows-32@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.41.tgz#0e7a64b739045596a83ecd9cb0af8c0d85e4d5ed" + integrity sha512-jS+/pGyPPzrL8tgcvOxLEatV1QPICghKm13EjEVgkeRftl8X6tqRyFv/9eKutczdD3sklMDOJfivoPD32D46Ww== + +esbuild-windows-64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.41.tgz#14886c72949e8630160d2277e21fae59778c2b62" + integrity sha512-vLqmKbV8FJ7LFMrT3zEQpojnUUbXyqhRPVGnAYzc0ESY5yAuom4E9tL7KzZ5H8KEuCUf//AvbyxpE+yOcjpyjA== + +esbuild-windows-arm64@0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.41.tgz#ad425b8a9fe29bd38b3028af387b5e923e4c54e1" + integrity sha512-TOvj7kRTfpH4GPPmblvuMNf8oNJ3y2h7a6HttanVnc3QLMm5bNFYLSo6TShLOn0SbqFWGJwHIhGhw2JK96aVhg== + +esbuild@^0.14.41: + version "0.14.41" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.41.tgz#8e20be02a2efc813b87253e7b197beecced74f7d" + integrity sha512-uZl2CH5nwayLPi1Unhfk+vBBjD3FDlYQ+v24qAlj2oZMYQP8pFs1k3DK5ViD+keF3JnuV4K7JtqVvBmTDwVEbA== + optionalDependencies: + esbuild-android-64 "0.14.41" + esbuild-android-arm64 "0.14.41" + esbuild-darwin-64 "0.14.41" + esbuild-darwin-arm64 "0.14.41" + esbuild-freebsd-64 "0.14.41" + esbuild-freebsd-arm64 "0.14.41" + esbuild-linux-32 "0.14.41" + esbuild-linux-64 "0.14.41" + esbuild-linux-arm "0.14.41" + esbuild-linux-arm64 "0.14.41" + esbuild-linux-mips64le "0.14.41" + esbuild-linux-ppc64le "0.14.41" + esbuild-linux-riscv64 "0.14.41" + esbuild-linux-s390x "0.14.41" + esbuild-netbsd-64 "0.14.41" + esbuild-openbsd-64 "0.14.41" + esbuild-sunos-64 "0.14.41" + esbuild-windows-32 "0.14.41" + esbuild-windows-64 "0.14.41" + esbuild-windows-arm64 "0.14.41" + escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -3404,7 +3530,7 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== -less@^4.1.1: +less@^4.1.1, less@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/less/-/less-4.1.2.tgz#6099ee584999750c2624b65f80145f8674e4b4b0" integrity sha512-EoQp/Et7OSOVu0aJknJOtlXZsnr8XE8KwuzTHOLeVSEx8pVWUICc8Q0VYRHgzyjX78nMEyC/oztWFbgyhtNfDA== @@ -4813,15 +4939,6 @@ terser@^5.2.0: source-map "~0.7.2" source-map-support "~0.5.20" -terser@^5.9.0: - version "5.9.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.9.0.tgz#47d6e629a522963240f2b55fcaa3c99083d2c351" - integrity sha512-h5hxa23sCdpzcye/7b8YqbE5OwKca/ni0RQz1uRX3tGh8haaGHqcuSqbGRybuAKNdntZ0mDgFNXPJ48xQ2RXKQ== - dependencies: - commander "^2.20.0" - source-map "~0.7.2" - source-map-support "~0.5.20" - test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" diff --git a/redisinsight/ui/src/pages/browser/BrowserPage.tsx b/redisinsight/ui/src/pages/browser/BrowserPage.tsx index 218103b6b3..36ee3482b7 100644 --- a/redisinsight/ui/src/pages/browser/BrowserPage.tsx +++ b/redisinsight/ui/src/pages/browser/BrowserPage.tsx @@ -6,7 +6,7 @@ import { EuiResizableContainer } from '@elastic/eui' import { formatLongName, getDbIndex, Nullable, setTitle } from 'uiSrc/utils' import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' -import { sendPageViewTelemetry, TelemetryPageView } from 'uiSrc/telemetry' +import { getBasedOnViewTypeEvent, sendEventTelemetry, sendPageViewTelemetry, TelemetryEvent, TelemetryPageView } from 'uiSrc/telemetry' import { fetchKeys, fetchMoreKeys, @@ -54,7 +54,7 @@ const BrowserPage = () => { } = useSelector(appContextBrowser) const keysState = useSelector(keysDataSelector) const { loading, viewType, isBrowserFullScreen } = useSelector(keysSelector) - const { type } = useSelector(selectedKeyDataSelector) ?? { type: '' } + const { type, length } = useSelector(selectedKeyDataSelector) ?? { type: '', length: 0 } const [isPageViewSent, setIsPageViewSent] = useState(false) const [arePanelsCollapsed, setArePanelsCollapsed] = useState(false) @@ -114,6 +114,21 @@ const BrowserPage = () => { const handleToggleFullScreen = () => { dispatch(toggleBrowserFullScreen()) + + const browserViewEvent = !isBrowserFullScreen + ? TelemetryEvent.BROWSER_KEY_DETAILS_FULL_SCREEN_ENABLED + : TelemetryEvent.BROWSER_KEY_DETAILS_FULL_SCREEN_DISABLED + const treeViewEvent = !isBrowserFullScreen + ? TelemetryEvent.TREE_VIEW_KEY_DETAILS_FULL_SCREEN_ENABLED + : TelemetryEvent.TREE_VIEW_KEY_DETAILS_FULL_SCREEN_DISABLED + sendEventTelemetry({ + event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), + eventData: { + databaseId: instanceId, + keyType: type, + length, + } + }) } const sendPageView = (instanceId: string) => { diff --git a/redisinsight/ui/src/pages/browser/components/auto-refresh/AutoRefresh.tsx b/redisinsight/ui/src/pages/browser/components/auto-refresh/AutoRefresh.tsx index 7c22a8c633..5da6be54a5 100644 --- a/redisinsight/ui/src/pages/browser/components/auto-refresh/AutoRefresh.tsx +++ b/redisinsight/ui/src/pages/browser/components/auto-refresh/AutoRefresh.tsx @@ -29,7 +29,7 @@ export interface Props { testid?: string containerClassName?: string turnOffAutoRefresh?: boolean - onRefresh: (enableAutoRefresh?: boolean) => void + onRefresh: (enableAutoRefresh: boolean) => void onEnableAutoRefresh?: (enableAutoRefresh: boolean, refreshRate: string) => void onChangeAutoRefreshRate?: (enableAutoRefresh: boolean, refreshRate: string) => void } @@ -49,7 +49,7 @@ const AutoRefresh = ({ onChangeAutoRefreshRate, }: Props) => { let intervalText: NodeJS.Timeout - let timeoutRefresh: NodeJS.Timeout + let intervalRefresh: NodeJS.Timeout const [refreshMessage, setRefreshMessage] = useState(NOW) const [isPopoverOpen, setIsPopoverOpen] = useState(false) @@ -74,7 +74,7 @@ const AutoRefresh = ({ useEffect(() => { if (turnOffAutoRefresh && enableAutoRefresh) { setEnableAutoRefresh(false) - clearInterval(timeoutRefresh) + clearInterval(intervalRefresh) } }, [turnOffAutoRefresh]) @@ -96,20 +96,20 @@ const AutoRefresh = ({ updateLastRefresh() if (enableAutoRefresh && !loading) { - timeoutRefresh = setInterval(() => { + intervalRefresh = setInterval(() => { if (document.hidden) return handleRefresh() }, +refreshRate * 1_000) } else { - clearInterval(timeoutRefresh) + clearInterval(intervalRefresh) } if (enableAutoRefresh) { updateAutoRefreshText(refreshRate) } - return () => clearInterval(timeoutRefresh) + return () => clearInterval(intervalRefresh) }, [enableAutoRefresh, refreshRate, loading, lastRefreshTime]) const getLastRefreshDelta = (time:Nullable) => (Date.now() - (time || 0)) / 1_000 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 1ec20e4972..386284394c 100644 --- a/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx +++ b/redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx @@ -159,7 +159,7 @@ const HashDetails = (props: Props) => { }) } setMatch(match) - dispatch(fetchHashFields(key, 0, SCAN_COUNT_DEFAULT, match || matchAllValue, onSuccess)) + dispatch(fetchHashFields(key, 0, SCAN_COUNT_DEFAULT, match || matchAllValue, true, onSuccess)) } } diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.spec.tsx new file mode 100644 index 0000000000..b0c40cd795 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.spec.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { instance, mock } from 'ts-mockito' +import AddStreamGroup, { Props } from './AddStreamGroup' + +const GROUP_NAME_FIELD = 'group-name-field' +const ID_FIELD = 'id-field' + +const mockedProps = mock() + +describe('AddStreamGroup', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should set member value properly', () => { + render() + const groupNameInput = screen.getByTestId(GROUP_NAME_FIELD) + fireEvent.change( + groupNameInput, + { target: { value: 'group name' } } + ) + expect(groupNameInput).toHaveValue('group name') + }) + + it('should set score value properly if input wrong value', () => { + render() + const idInput = screen.getByTestId(ID_FIELD) + fireEvent.change( + idInput, + { target: { value: 'aa1x-5' } } + ) + expect(idInput).toHaveValue('1-5') + }) + + it('should able to save with valid data', () => { + render() + const groupNameInput = screen.getByTestId(GROUP_NAME_FIELD) + const idInput = screen.getByTestId(ID_FIELD) + fireEvent.change( + groupNameInput, + { target: { value: 'name' } } + ) + fireEvent.change( + idInput, + { target: { value: '11111-3' } } + ) + expect(screen.getByTestId('save-groups-btn')).not.toBeDisabled() + }) + + it('should not able to save with valid data', () => { + render() + const groupNameInput = screen.getByTestId(GROUP_NAME_FIELD) + const idInput = screen.getByTestId(ID_FIELD) + fireEvent.change( + groupNameInput, + { target: { value: 'name' } } + ) + fireEvent.change( + idInput, + { target: { value: '11111----' } } + ) + expect(screen.getByTestId('save-groups-btn')).toBeDisabled() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx new file mode 100644 index 0000000000..5122a4aca2 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx @@ -0,0 +1,179 @@ +import { + EuiButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiPanel, + EuiTextColor, + EuiToolTip +} from '@elastic/eui' +import cx from 'classnames' +import React, { ChangeEvent, useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' + +import { lastDeliveredIDTooltipText } from 'uiSrc/constants/texts' +import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { addNewGroupAction } from 'uiSrc/slices/browser/stream' +import { consumerGroupIdRegex, validateConsumerGroupId } from 'uiSrc/utils' +import { CreateConsumerGroupsDto } from 'apiSrc/modules/browser/dto/stream.dto' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import styles from './styles.module.scss' + +export interface Props { + onCancel: (isCancelled?: boolean) => void +} + +const AddStreamGroup = (props: Props) => { + const { onCancel } = props + const { name: keyName = '' } = useSelector(selectedKeyDataSelector) ?? { name: undefined } + + const [isFormValid, setIsFormValid] = useState(false) + const [groupName, setGroupName] = useState('') + const [id, setId] = useState('$') + const [idError, setIdError] = useState('') + const [isIdFocused, setIsIdFocused] = useState(false) + + const { instanceId } = useParams<{ instanceId: string }>() + + const dispatch = useDispatch() + + useEffect(() => { + const isValid = !!groupName.length && !idError + setIsFormValid(isValid) + }, [groupName, idError]) + + useEffect(() => { + if (!consumerGroupIdRegex.test(id)) { + setIdError('ID format is not correct') + return + } + setIdError('') + }, [id]) + + const onSuccessAdded = () => { + onCancel() + sendEventTelemetry({ + event: TelemetryEvent.STREAM_CONSUMER_GROUP_CREATED, + eventData: { + databaseId: instanceId, + } + }) + } + + const submitData = () => { + if (isFormValid) { + const data: CreateConsumerGroupsDto = { + keyName, + consumerGroups: [{ + name: groupName, + lastDeliveredId: id, + }], + } + dispatch(addNewGroupAction(data, onSuccessAdded)) + } + } + + const showIdError = !isIdFocused && idError + + return ( + <> + + + + + + + + ) => setGroupName(e.target.value)} + autoComplete="off" + data-testid="group-name-field" + /> + + + + + ) => setId(validateConsumerGroupId(e.target.value))} + onBlur={() => setIsIdFocused(false)} + onFocus={() => setIsIdFocused(true)} + append={( + + + + )} + autoComplete="off" + data-testid="id-field" + /> + + {!showIdError && Timestamp - Sequence Number or $} + {showIdError && {idError}} + + + + + + + + + +
+ onCancel(true)} data-testid="cancel-stream-groups-btn"> + Cancel + +
+
+ +
+ + Save + +
+
+
+
+ + ) +} + +export default AddStreamGroup diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/index.ts b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/index.ts new file mode 100644 index 0000000000..cdce9fc4da --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/index.ts @@ -0,0 +1,3 @@ +import AddStreamGroup from './AddStreamGroup' + +export default AddStreamGroup diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/styles.module.scss new file mode 100644 index 0000000000..3536c4aecb --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/styles.module.scss @@ -0,0 +1,30 @@ +.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; + + .groupNameWrapper { + flex-grow: 2 !important; + } + + .timestampWrapper { + min-width: 215px; + } + + .idText, .error { + display: inline-block; + color: var(--euiColorMediumShade); + font: normal normal normal 12px/18px Graphik; + margin-top: 6px; + padding-right: 6px; + } + + .error { + color: var(--euiColorDangerText); + } +} 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 index a37d080cac..c389c49cbe 100644 --- 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 @@ -2,6 +2,7 @@ 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 AddStreamGroup from './add-stream-group' import AddZsetMembers from './add-zset-members/AddZsetMembers' export { @@ -10,5 +11,6 @@ export { AddSetMembers, AddStreamEntries, StreamEntryFields, - AddZsetMembers + AddZsetMembers, + AddStreamGroup } 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 742140a210..1ee09dd84c 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 @@ -6,25 +6,26 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiLoadingContent, EuiPopover, EuiText, EuiToolTip, - EuiLoadingContent, } from '@elastic/eui' +import cx from 'classnames' +import { isNull } from 'lodash' import React, { ChangeEvent, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' -import { isNull } from 'lodash' -import cx from 'classnames' import AutoSizer from 'react-virtualized-auto-sizer' import { GroupBadge } from 'uiSrc/components' -import { KeyTypes, KEY_TYPES_ACTIONS, LENGTH_NAMING_BY_TYPE, ModulesKeyTypes } from 'uiSrc/constants' -import { selectedKeyDataSelector, selectedKeySelector, keysSelector } from 'uiSrc/slices/browser/keys' +import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' +import { KEY_TYPES_ACTIONS, KeyTypes, LENGTH_NAMING_BY_TYPE, ModulesKeyTypes, STREAM_ADD_ACTION } from 'uiSrc/constants' +import { AddCommonFieldsFormConfig } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' +import { keysSelector, selectedKeyDataSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { streamSelector } from 'uiSrc/slices/browser/stream' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { getBasedOnViewTypeEvent, getRefreshEventData, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { formatBytes, formatNameShort, MAX_TTL_NUMBER, replaceSpaces, validateTTLNumber } from 'uiSrc/utils' -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' @@ -75,6 +76,7 @@ const KeyDetailsHeader = ({ const { ttl: ttlProp, name: keyProp = '', type, size, length } = useSelector(selectedKeyDataSelector) ?? initialKeyInfo const { id: instanceId } = useSelector(connectedInstanceSelector) const { viewType } = useSelector(keysSelector) + const { viewType: streamViewType } = useSelector(streamSelector) const [isPopoverDeleteOpen, setIsPopoverDeleteOpen] = useState(false) @@ -93,7 +95,7 @@ const KeyDetailsHeader = ({ const keyNameRef = useRef(null) - const tooltipContent = formatNameShort(keyProp) + const tooltipContent = formatNameShort(keyProp || '') const onMouseEnterKey = () => { setKeyIsHovering(true) @@ -180,21 +182,50 @@ const KeyDetailsHeader = ({ const handleRefreshKey = (enableAutoRefresh: boolean) => { if (!enableAutoRefresh) { + const eventData = getRefreshEventData( + { + databaseId: instanceId, + keyType: type + }, + type, + streamViewType + ) sendEventTelemetry({ event: getBasedOnViewTypeEvent( viewType, TelemetryEvent.BROWSER_KEY_DETAILS_REFRESH_CLICKED, TelemetryEvent.TREE_VIEW_KEY_DETAILS_REFRESH_CLICKED ), - eventData: { - databaseId: instanceId, - keyType: type - } + eventData }) } onRefresh(key, type) } + const handleEnableAutoRefresh = (enableAutoRefresh: boolean, refreshRate: string) => { + const browserViewEvent = enableAutoRefresh + ? TelemetryEvent.BROWSER_KEY_DETAILS_AUTO_REFRESH_ENABLED + : TelemetryEvent.BROWSER_KEY_DETAILS_AUTO_REFRESH_DISABLED + const treeViewEvent = enableAutoRefresh + ? TelemetryEvent.TREE_VIEW_KEY_DETAILS_AUTO_REFRESH_ENABLED + : TelemetryEvent.TREE_VIEW_KEY_DETAILS_AUTO_REFRESH_DISABLED + sendEventTelemetry({ + event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), + eventData: { + length, + databaseId: instanceId, + keyType: type, + refreshRate: +refreshRate + } + }) + } + + const handleChangeAutoRefreshRate = (enableAutoRefresh: boolean, refreshRate: string) => { + if (enableAutoRefresh) { + handleEnableAutoRefresh(enableAutoRefresh, refreshRate) + } + } + const onMouseEnterTTL = () => { setTTLIsHovering(true) } @@ -266,7 +297,7 @@ const KeyDetailsHeader = ({ const Actions = (width: number) => ( <> - {'addItems' in KEY_TYPES_ACTIONS[keyType] && ( + {KEY_TYPES_ACTIONS[keyType] && 'addItems' in KEY_TYPES_ACTIONS[keyType] && ( MIDDLE_SCREEN_RESOLUTION ? '' : KEY_TYPES_ACTIONS[keyType].addItems?.name} position="left" @@ -296,7 +327,37 @@ const KeyDetailsHeader = ({ )} - {'removeItems' in KEY_TYPES_ACTIONS[keyType] && ( + {keyType === KeyTypes.Stream && ( + MIDDLE_SCREEN_RESOLUTION ? '' : STREAM_ADD_ACTION[streamViewType].name} + position="left" + anchorClassName={cx(styles.actionBtn, { [styles.withText]: width > MIDDLE_SCREEN_RESOLUTION })} + > + <> + {width > MIDDLE_SCREEN_RESOLUTION ? ( + + {STREAM_ADD_ACTION[streamViewType].name} + + ) : ( + + )} + + + )} + {KEY_TYPES_ACTIONS[keyType] && 'removeItems' in KEY_TYPES_ACTIONS[keyType] && ( )} - {'editItem' in KEY_TYPES_ACTIONS[keyType] && ( + {KEY_TYPES_ACTIONS[keyType] && 'editItem' in KEY_TYPES_ACTIONS[keyType] && (
- {keyIsEditing || keyIsHovering ? ( + {(keyIsEditing || keyIsHovering) && ( - ) : ( - - - {replaceSpaces(keyProp?.substring(0, 200))} - - )} + + + {replaceSpaces(keyProp?.substring(0, 200))} + + {!arePanelsCollapsed && ( @@ -496,60 +556,61 @@ const KeyDetailsHeader = ({ className={styles.flexItemTTL} data-testid="edit-ttl-btn" > - {ttlIsEditing || ttlIsHovering ? ( - - - - TTL: - - - - applyEditTTL()} - onDecline={(event) => cancelEditTTl(event)} - viewChildrenMode={!ttlIsEditing} - isLoading={loading} - declineOnUnmount={false} - > - + {(ttlIsEditing || ttlIsHovering) && ( + + + + TTL: + + + + applyEditTTL()} + onDecline={(event) => cancelEditTTl(event)} + viewChildrenMode={!ttlIsEditing} isLoading={loading} - onChange={onChangeTtl} - append={appendTTLEditing()} - autoComplete="off" - data-testid="edit-ttl-input" - /> - - - - ) : ( + declineOnUnmount={false} + > + + + + + )} TTL: @@ -557,7 +618,7 @@ const KeyDetailsHeader = ({ {ttl === '-1' ? 'No limit' : ttl} - )} +
@@ -566,11 +627,13 @@ const KeyDetailsHeader = ({ loading={loading} lastRefreshTime={lastRefreshTime} displayText={width > HIDE_LAST_REFRESH} - onRefresh={handleRefreshKey} containerClassName={styles.actionBtn} + onRefresh={handleRefreshKey} + onEnableAutoRefresh={handleEnableAutoRefresh} + onChangeAutoRefreshRate={handleChangeAutoRefreshRate} testid="refresh-key-btn" /> - {(keyType && KEY_TYPES_ACTIONS[keyType]) && Actions(width)} + {keyType && Actions(width)} { const isKeySelected = !isNull(useSelector(selectedKeyDataSelector)) const { id: instanceId } = useSelector(connectedInstanceSelector) const { viewType } = useSelector(keysSelector) + const { viewType: streamViewType } = useSelector(streamSelector) const [isAddItemPanelOpen, setIsAddItemPanelOpen] = useState(false) const [isRemoveItemPanelOpen, setIsRemoveItemPanelOpen] = useState(false) const [editItem, setEditItem] = useState(false) @@ -76,17 +79,19 @@ const KeyDetails = ({ ...props }: Props) => { const openAddItemPanel = () => { setIsRemoveItemPanelOpen(false) setIsAddItemPanelOpen(true) - sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_ADD_VALUE_CLICKED, - TelemetryEvent.TREE_VIEW_KEY_ADD_VALUE_CLICKED - ), - eventData: { - databaseId: instanceId, - keyType: selectedKeyType - } - }) + if (!STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType)) { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_ADD_VALUE_CLICKED, + TelemetryEvent.TREE_VIEW_KEY_ADD_VALUE_CLICKED + ), + eventData: { + databaseId: instanceId, + keyType: selectedKeyType + } + }) + } } const openRemoveItemPanel = () => { @@ -95,7 +100,7 @@ const KeyDetails = ({ ...props }: Props) => { } const closeAddItemPanel = (isCancelled?: boolean) => { - if (isCancelled && isAddItemPanelOpen) { + if (isCancelled && isAddItemPanelOpen && !STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType)) { sendEventTelemetry({ event: getBasedOnViewTypeEvent( viewType, @@ -203,7 +208,14 @@ const KeyDetails = ({ ...props }: Props) => { )} {selectedKeyType === KeyTypes.Stream && ( - + <> + {streamViewType === StreamViewType.Data && ( + + )} + {STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType) && ( + + )} + )}
)} 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 2986d963af..3faab25a42 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.tsx @@ -1,11 +1,15 @@ import React, { useEffect } from 'react' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' + import { deleteKeyAction, editKey, editKeyTTL, fetchKeyInfo, + keysSelector, refreshKeyInfoAction, + selectedKeyDataSelector, toggleBrowserFullScreen, } from 'uiSrc/slices/browser/keys' import { KeyTypes, ModulesKeyTypes } from 'uiSrc/constants' @@ -15,7 +19,8 @@ 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 { refreshStream } from 'uiSrc/slices/browser/stream' +import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import KeyDetails from './KeyDetails/KeyDetails' export interface Props { @@ -39,6 +44,12 @@ const KeyDetailsWrapper = (props: Props) => { keyProp } = props + const { instanceId } = useParams<{ instanceId: string }>() + const { viewType } = useSelector(keysSelector) + const { type: keyType, name: keyName, length: keyLength } = useSelector(selectedKeyDataSelector) ?? { + type: KeyTypes.String, + } + const dispatch = useDispatch() useEffect(() => { @@ -50,6 +61,21 @@ const KeyDetailsWrapper = (props: Props) => { dispatch(fetchKeyInfo(keyProp)) }, [keyProp]) + useEffect(() => { + sendEventTelemetry({ + event: getBasedOnViewTypeEvent( + viewType, + TelemetryEvent.BROWSER_KEY_VALUE_VIEWED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_VIEWED + ), + eventData: { + keyType, + databaseId: instanceId, + length: keyLength, + } + }) + }, [keyName]) + const handleDeleteKey = (key: string, type: string) => { if (type === KeyTypes.String) { dispatch(deleteKeyAction(key, () => { @@ -90,7 +116,7 @@ const KeyDetailsWrapper = (props: Props) => { break } case KeyTypes.Stream: { - dispatch(refreshStreamEntries(key, resetData)) + dispatch(refreshStream(key, resetData)) break } default: 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 c84a6d8661..1e9a404ec5 100644 --- a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx @@ -108,7 +108,7 @@ const KeyList = (props: Props) => { const tooltipContent = formatLongName(cellData) return ( -
+
{ )) } + const handleEnableAutoRefresh = (enableAutoRefresh: boolean, refreshRate: string) => { + const browserViewEvent = enableAutoRefresh + ? TelemetryEvent.BROWSER_KEY_LIST_AUTO_REFRESH_ENABLED + : TelemetryEvent.BROWSER_KEY_LIST_AUTO_REFRESH_DISABLED + const treeViewEvent = enableAutoRefresh + ? TelemetryEvent.TREE_VIEW_KEY_LIST_AUTO_REFRESH_ENABLED + : TelemetryEvent.TREE_VIEW_KEY_LIST_AUTO_REFRESH_DISABLED + sendEventTelemetry({ + event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), + eventData: { + databaseId: instanceId, + refreshRate: +refreshRate, + } + }) + } + + const handleChangeAutoRefreshRate = (enableAutoRefresh: boolean, refreshRate: string) => { + if (enableAutoRefresh) { + handleEnableAutoRefresh(enableAutoRefresh, refreshRate) + } + } + const handleScanMore = (config: any) => { loadMoreItems?.({ ...config, @@ -233,6 +255,8 @@ const KeysHeader = (props: Props) => { displayText={width > HIDE_REFRESH_LABEL_WIDTH} containerClassName={styles.refreshContainer} onRefresh={handleRefreshKeys} + onEnableAutoRefresh={handleEnableAutoRefresh} + onChangeAutoRefreshRate={handleChangeAutoRefreshRate} testid="refresh-keys-btn" />
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 7b0dee2f43..ec56e59fea 100644 --- a/redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx +++ b/redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx @@ -34,7 +34,8 @@ const PopoverDelete = (props: Props) => { testid = '', } = props - const onButtonClick = () => { + const onButtonClick = (e: React.MouseEvent) => { + e.stopPropagation() if (item + suffix !== deleting) { showPopover(item) handleButtonClick?.() @@ -62,6 +63,7 @@ const PopoverDelete = (props: Props) => { data-testid={testid ? `${testid}-icon` : 'remove-icon'} /> )} + onClick={(e) => e.stopPropagation()} >
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 deleted file mode 100644 index 5171aa482b..0000000000 --- a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/StreamDetails.tsx +++ /dev/null @@ -1,274 +0,0 @@ -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 deleted file mode 100644 index 7e4044b399..0000000000 --- a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import StreamDetails from './StreamDetails' - -export default StreamDetails 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 index 93a4c23b59..237a80099a 100644 --- a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.spec.tsx @@ -1,6 +1,6 @@ import React from 'react' import { instance, mock } from 'ts-mockito' -import { render } from 'uiSrc/utils/test-utils' +import { render, screen } from 'uiSrc/utils/test-utils' import StreamDetailsWrapper, { Props } from './StreamDetailsWrapper' const mockedProps = mock() @@ -9,4 +9,10 @@ describe('StreamDetailsWrapper', () => { it('should render', () => { expect(render()).toBeTruthy() }) + + it('should render Stream Data container', () => { + render() + + expect(screen.getByTestId('stream-entries-container')).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.tsx index 274abf5e30..c3a83d14e8 100644 --- a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.tsx +++ b/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetailsWrapper.tsx @@ -1,229 +1,221 @@ -import { EuiText, EuiToolTip } from '@elastic/eui' -import React, { useCallback, useEffect, useState } from 'react' +import { EuiProgress } from '@elastic/eui' +import React, { useCallback, useEffect, useMemo } 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 { isNull, last } from 'lodash' +import cx from 'classnames' + +import { + streamSelector, + streamGroupsSelector, + streamRangeSelector, + streamDataSelector, + fetchMoreStreamEntries, + updateStart, + updateEnd, + fetchStreamEntries +} from 'uiSrc/slices/browser/stream' +import { StreamViewType } from 'uiSrc/slices/interfaces/stream' 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 { +import { getNextId, getTimestampFromId } from 'uiSrc/utils/streamUtils' +import { SortOrder } from 'uiSrc/constants' +import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' +import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import RangeFilter from 'uiSrc/components/range-filter' +import { GetStreamEntriesResponse } from 'apiSrc/modules/browser/dto/stream.dto' + +import ConsumersViewWrapper from './consumers-view' +import GroupsViewWrapper from './groups-view' +import MessagesViewWrapper from './messages-view' +import StreamDataViewWrapper from './stream-data-view' +import StreamTabs from './stream-tabs' + +import styles from './styles.module.scss' + +export interface Props { isFooterOpen: boolean } const StreamDetailsWrapper = (props: Props) => { - const { - entries: loadedEntries = [], - keyName: key - } = useSelector(streamDataSelector) + const { viewType, loading, sortOrder: entryColumnSortOrder } = useSelector(streamSelector) + const { loading: loadingGroups } = useSelector(streamGroupsSelector) + const { start, end } = useSelector(streamRangeSelector) + const { firstEntry, lastEntry, entries, } = useSelector(streamDataSelector) + const { name: key } = useSelector(selectedKeyDataSelector) ?? { name: '' } 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('') + const firstEntryTimeStamp = useMemo(() => getTimestampFromId(firstEntry?.id), [firstEntry?.id]) + const lastEntryTimeStamp = useMemo(() => getTimestampFromId(lastEntry?.id), [lastEntry?.id]) - useEffect(() => { - let fields = {} - const streamEntries: IStreamEntry[] = loadedEntries?.map((item) => { - fields = { - ...fields, - ...keyBy(Object.keys(item.fields)) - } + const startNumber = useMemo(() => (start === '' ? 0 : parseInt(start, 10)), [start]) + const endNumber = useMemo(() => (end === '' ? 0 : parseInt(end, 10)), [end]) - return { - ...item, - editing: false, - } - }) + const shouldFilterRender = !isNull(firstEntry) + && (firstEntry.id !== '') + && !isNull(lastEntry) + && lastEntry.id !== '' + + useEffect(() => { + if (isNull(firstEntry)) { + dispatch(updateStart('')) + } + if (start === '' && firstEntry?.id !== '') { + dispatch(updateStart(firstEntryTimeStamp.toString())) + } + }, [firstEntryTimeStamp]) - setUniqFields(fields) - setEntries(streamEntries) - setColumns([idColumn, ...Object.keys(fields).map((field) => getTemplateColumn(field)), actionsColumn]) - }, [loadedEntries, deleting]) + useEffect(() => { + if (isNull(lastEntry)) { + dispatch(updateEnd('')) + } + if (end === '' && lastEntry?.id !== '') { + dispatch(updateEnd(lastEntryTimeStamp.toString())) + } + }, [lastEntryTimeStamp]) - const closePopover = useCallback(() => { - setDeleting('') - }, []) + const loadMoreItems = () => { + const lastLoadedEntryId = last(entries)?.id ?? '' + const lastLoadedEntryTimeStamp = getTimestampFromId(lastLoadedEntryId) - const showPopover = useCallback((entry = '') => { - setDeleting(`${entry + suffix}`) - }, []) + 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 entryColumnSortOrder === SortOrder.ASC + ? lastLoadedEntryTimeStamp <= lastRangeEntryTimestamp + : lastLoadedEntryTimeStamp >= firstRangeEntryTimestamp + } + const nextId = getNextId(lastLoadedEntryId, entryColumnSortOrder) + + if (shouldLoadMore()) { + dispatch( + fetchMoreStreamEntries( + key, + entryColumnSortOrder === SortOrder.DESC ? start : nextId, + entryColumnSortOrder === SortOrder.DESC ? nextId : end, + SCAN_COUNT_DEFAULT, + entryColumnSortOrder, + ) + ) + } + } - const onSuccessRemoved = () => { + const filterTelemetry = (data: GetStreamEntriesResponse) => { sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_REMOVED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVED - ), + event: TelemetryEvent.STREAM_DATA_FILTERED, eventData: { databaseId: instanceId, - keyType: KeyTypes.Stream, - numberOfRemoved: 1, + total: data.total, } }) } - const handleDeleteEntry = (entryId = '') => { - dispatch(deleteStreamEntry(key, [entryId], onSuccessRemoved)) - closePopover() - } - - const handleRemoveIconClick = () => { + const resetFilterTelemetry = (data: GetStreamEntriesResponse) => { sendEventTelemetry({ - event: getBasedOnViewTypeEvent( - viewType, - TelemetryEvent.BROWSER_KEY_VALUE_REMOVE_CLICKED, - TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVE_CLICKED - ), + event: TelemetryEvent.STREAM_DATA_FILTER_RESET, eventData: { databaseId: instanceId, - keyType: KeyTypes.Stream + total: data.total, } }) } - const handleEditEntry = (entryId = '', editing: boolean) => { - const newFieldsState = entries.map((item) => { - if (item.id === entryId) { - return { ...item, editing } - } - return item - }) - setEntries(newFieldsState) + const loadEntries = (telemetryAction?: (data: GetStreamEntriesResponse) => void) => { + dispatch(fetchStreamEntries( + key, + SCAN_COUNT_DEFAULT, + entryColumnSortOrder, + false, + telemetryAction + )) } - 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} -
-
-
- ) - }, + const handleChangeStartFilter = useCallback( + (value: number, shouldSentEventTelemetry: boolean) => { + dispatch(updateStart(value.toString())) + loadEntries(shouldSentEventTelemetry ? filterTelemetry : undefined) }, - { - 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} - /> -
- ) - }, + [] + ) + + const handleChangeEndFilter = useCallback( + (value: number, shouldSentEventTelemetry: boolean) => { + dispatch(updateEnd(value.toString())) + loadEntries(shouldSentEventTelemetry ? filterTelemetry : undefined) }, - ] + [] + ) + + const handleResetFilter = useCallback( + () => { + dispatch(updateStart(firstEntryTimeStamp.toString())) + dispatch(updateEnd(lastEntryTimeStamp.toString())) + loadEntries(resetFilterTelemetry) + }, + [lastEntryTimeStamp, firstEntryTimeStamp] + ) + + const handleUpdateRangeMin = useCallback( + (min: number) => { + dispatch(updateStart(min.toString())) + }, + [] + ) + + const handleUpdateRangeMax = useCallback( + (max: number) => { + dispatch(updateEnd(max.toString())) + }, + [] + ) return ( - <> - - +
+ {(loading || loadingGroups) && ( + + )} + {shouldFilterRender ? ( + + ) + : ( +
+
+
+ )} + + {viewType === StreamViewType.Data && ( + + )} + {viewType === StreamViewType.Groups && ( + + )} + {viewType === StreamViewType.Consumers && ( + + )} + {viewType === StreamViewType.Messages && ( + + )} +
) } diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/constants.ts b/redisinsight/ui/src/pages/browser/components/stream-details/constants.ts new file mode 100644 index 0000000000..ebc93b1eae --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/constants.ts @@ -0,0 +1,19 @@ +import React from 'react' +import { StreamViewType } from 'uiSrc/slices/interfaces/stream' + +interface StreamTabs { + id: StreamViewType, + label: string, + separator?: React.ReactElement +} + +export const streamViewTypeTabs: StreamTabs[] = [ + { + id: StreamViewType.Data, + label: 'Stream Data', + }, + { + id: StreamViewType.Groups, + label: 'Consumer Groups', + }, +] diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/ConsumersView.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/ConsumersView.spec.tsx new file mode 100644 index 0000000000..230c3136a4 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/ConsumersView.spec.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { ConsumerDto } from 'apiSrc/modules/browser/dto/stream.dto' +import ConsumersView, { Props } from './ConsumersView' + +const mockedProps = mock() +const mockConsumers: ConsumerDto[] = [{ + name: 'test', + idle: 123, + pending: 321, +}, { + name: 'test2', + idle: 13, + pending: 31, +}] + +describe('ConsumersView', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/ConsumersView.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/ConsumersView.tsx new file mode 100644 index 0000000000..f599a81633 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/ConsumersView.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import cx from 'classnames' +import { orderBy } from 'lodash' + +import { + streamGroupsSelector, +} from 'uiSrc/slices/browser/stream' +import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' +import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' +import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { SortOrder } from 'uiSrc/constants' +import { ConsumerDto } from 'apiSrc/modules/browser/dto/stream.dto' + +import styles from './styles.module.scss' + +const headerHeight = 60 +const rowHeight = 54 +const noItemsMessageString = 'Your Consumer Group has no Consumers available.' + +export interface Props { + data: ConsumerDto[] + columns: ITableColumn[] + onClosePopover: () => void + onSelectConsumer: ({ rowData }: { rowData: any }) => void + isFooterOpen?: boolean +} + +const ConsumersView = (props: Props) => { + const { data = [], columns = [], onClosePopover, onSelectConsumer, isFooterOpen } = props + + const { loading } = useSelector(streamGroupsSelector) + const { name: key = '' } = useSelector(selectedKeyDataSelector) ?? { } + + const [consumers, setConsumers] = useState(data) + const [sortedColumnName, setSortedColumnName] = useState('name') + const [sortedColumnOrder, setSortedColumnOrder] = useState(SortOrder.ASC) + + useEffect(() => { + setConsumers(orderBy(data, sortedColumnName, sortedColumnOrder?.toLowerCase())) + }, [data]) + + const onChangeSorting = (column: any, order: SortOrder) => { + setSortedColumnName(column) + setSortedColumnOrder(order) + + setConsumers(orderBy(consumers, column, order?.toLowerCase())) + } + + return ( + <> +
+ +
+ + ) +} + +export default ConsumersView diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/index.ts b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/index.ts new file mode 100644 index 0000000000..70a36afbd5 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/index.ts @@ -0,0 +1,5 @@ +import ConsumersView from './ConsumersView' + +export * from './ConsumersView' + +export default ConsumersView diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/styles.module.scss b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/styles.module.scss new file mode 100644 index 0000000000..eefd0f7322 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersView/styles.module.scss @@ -0,0 +1,12 @@ +.container { + display: flex; + flex: 1; + width: 100%; + padding-top: 3px; + background-color: var(--euiColorEmptyShade); +} + +.actions, +.actionsHeader { + width: 54px; +} diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.spec.tsx new file mode 100644 index 0000000000..891b54f764 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.spec.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { cloneDeep } from 'lodash' +import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' +import { + deleteConsumers, + loadConsumerGroups, + setSelectedConsumer +} from 'uiSrc/slices/browser/stream' +import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' +import { ConsumerDto } from 'apiSrc/modules/browser/dto/stream.dto' +import ConsumersView, { Props as ConsumersViewProps } from './ConsumersView' +import ConsumersViewWrapper, { Props } from './ConsumersViewWrapper' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('./ConsumersView', () => ({ + __esModule: true, + namedExport: jest.fn(), + default: jest.fn(), +})) + +const mockConsumerName = 'group' +const mockConsumers: ConsumerDto[] = [{ + name: 'test', + idle: 123, + pending: 321, +}, { + name: 'test2', + idle: 13, + pending: 31, +}] + +const mockConsumersView = (props: ConsumersViewProps) => ( +
+ + + +
+) + +describe('ConsumersViewWrapper', () => { + beforeAll(() => { + ConsumersView.mockImplementation(mockConsumersView) + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render Consumers container', () => { + render() + + expect(screen.getByTestId('stream-consumers-container')).toBeInTheDocument() + }) + + it('should select Consumer', () => { + render() + + const afterRenderActions = [...store.getActions()] + + fireEvent.click(screen.getByTestId('select-consumer-btn')) + + expect(store.getActions()).toEqual([...afterRenderActions, setSelectedConsumer(), loadConsumerGroups(false)]) + }) + + it('should delete Consumer', () => { + render() + + const afterRenderActions = [...store.getActions()] + + fireEvent.click(screen.getByTestId('remove-consumer-button-test-icon')) + fireEvent.click(screen.getByTestId('remove-consumer-button-test')) + + expect(store.getActions()).toEqual([...afterRenderActions, deleteConsumers()]) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.tsx new file mode 100644 index 0000000000..6f8d610f2c --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.tsx @@ -0,0 +1,195 @@ +import React, { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import { EuiToolTip, EuiText } from '@elastic/eui' + +import { + setStreamViewType, + selectedGroupSelector, + setSelectedConsumer, + fetchConsumerMessages, + deleteConsumersAction +} from 'uiSrc/slices/browser/stream' +import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' +import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' +import { TableCellAlignment, TableCellTextAlignment } from 'uiSrc/constants' +import { StreamViewType } from 'uiSrc/slices/interfaces/stream' +import { numberWithSpaces } from 'uiSrc/utils/numbers' +import { selectedKeyDataSelector, updateSelectedKeyRefreshTime } from 'uiSrc/slices/browser/keys' +import { formatLongName } from 'uiSrc/utils' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import { ConsumerDto } from 'apiSrc/modules/browser/dto/stream.dto' +import ConsumersView from './ConsumersView' + +import styles from './ConsumersView/styles.module.scss' + +const suffix = '_stream_consumer' +const actionsWidth = 50 + +export interface Props { + isFooterOpen: boolean +} + +const ConsumersViewWrapper = (props: Props) => { + const { name: key = '' } = useSelector(selectedKeyDataSelector) ?? { name: '' } + const { + name: selectedGroupName = '', + lastRefreshTime, + data: loadedConsumers = [], + } = useSelector(selectedGroupSelector) ?? {} + + const { instanceId } = useParams<{ instanceId: string }>() + + const dispatch = useDispatch() + + const [deleting, setDeleting] = useState('') + + useEffect(() => { + dispatch(updateSelectedKeyRefreshTime(lastRefreshTime)) + }, []) + + const closePopover = () => { + setDeleting('') + } + + const showPopover = (consumer = '') => { + setDeleting(`${consumer + suffix}`) + } + + const onSuccessDeletedConsumer = () => { + sendEventTelemetry({ + event: TelemetryEvent.STREAM_CONSUMER_DELETED, + eventData: { + databaseId: instanceId, + } + }) + closePopover() + } + + const handleDeleteConsumer = (consumerName = '') => { + dispatch(deleteConsumersAction(key, selectedGroupName, [consumerName], onSuccessDeletedConsumer)) + } + + 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 handleSelectConsumer = ({ rowData }: { rowData: any }) => { + dispatch(setSelectedConsumer(rowData)) + dispatch(fetchConsumerMessages( + false, + () => dispatch(setStreamViewType(StreamViewType.Messages)) + )) + } + + const columns: ITableColumn[] = [ + + { + id: 'name', + label: 'Consumer Name', + relativeWidth: 0.59, + truncateText: true, + isSortable: true, + headerClassName: 'streamItemHeader', + headerCellClassName: 'truncateText', + render: function Name(_name: string, { name }: ConsumerDto) { + // Better to cut the long string, because it could affect virtual scroll performance + const cellContent = name.substring(0, 200) + const tooltipContent = formatLongName(name) + return ( + +
+ + <>{cellContent} + +
+
+ ) + }, + }, + { + id: 'pending', + label: 'Pending', + minWidth: 106, + relativeWidth: 0.12, + truncateText: true, + isSortable: true, + headerClassName: 'streamItemHeader', + headerCellClassName: 'truncateText', + }, + { + id: 'idle', + label: 'Idle Time, ms', + minWidth: 190, + relativeWidth: 0.27, + isSortable: true, + alignment: TableCellAlignment.Right, + className: styles.cell, + headerClassName: 'streamItemHeader', + headerCellClassName: 'truncateText', + render: (cellData: number) => numberWithSpaces(cellData), + }, + { + id: 'actions', + label: '', + headerClassName: 'streamItemHeader', + textAlignment: TableCellTextAlignment.Left, + absoluteWidth: actionsWidth, + maxWidth: actionsWidth, + minWidth: actionsWidth, + render: function Actions(_act: any, { name }: ConsumerDto) { + return ( +
+ + will be removed from Consumer Group {selectedGroupName} + + )} + item={name} + suffix={suffix} + deleting={deleting} + closePopover={closePopover} + updateLoading={false} + showPopover={showPopover} + testid={`remove-consumer-button-${name}`} + handleDeleteItem={() => handleDeleteConsumer(name)} + handleButtonClick={handleRemoveIconClick} + /> +
+ ) + }, + }, + ] + + return ( + <> + + + ) +} + +export default ConsumersViewWrapper diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/index.ts b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/index.ts new file mode 100644 index 0000000000..fc011b573d --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/index.ts @@ -0,0 +1,5 @@ +import ConsumersViewWrapper from './ConsumersViewWrapper' + +export * from './ConsumersViewWrapper' + +export default ConsumersViewWrapper diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.spec.tsx new file mode 100644 index 0000000000..af25a610b2 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.spec.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import GroupsView, { Props, IConsumerGroup } from './GroupsView' + +const mockedProps = mock() + +const mockGroups: IConsumerGroup[] = [{ + name: 'test', + consumers: 123, + pending: 321, + smallestPendingId: '123', + greatestPendingId: '123', + lastDeliveredId: '123', + editing: false, +}, { + name: 'test2', + consumers: 13, + pending: 31, + smallestPendingId: '3', + greatestPendingId: '23', + lastDeliveredId: '12', + editing: false, +}] + +describe('GroupsView', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.tsx new file mode 100644 index 0000000000..5a739b347d --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import cx from 'classnames' +import { orderBy } from 'lodash' + +import { + streamGroupsSelector, +} from 'uiSrc/slices/browser/stream' +import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' +import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' +import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { SortOrder } from 'uiSrc/constants' +import { ConsumerGroupDto } from 'apiSrc/modules/browser/dto/stream.dto' + +import styles from './styles.module.scss' + +const headerHeight = 60 +const rowHeight = 54 +const noItemsMessageString = 'Your Key has no Consumer Groups available.' + +export interface IConsumerGroup extends ConsumerGroupDto { + editing: boolean +} + +export interface Props { + data: IConsumerGroup[] + columns: ITableColumn[] + onClosePopover: () => void + onSelectGroup: ({ rowData }: { rowData: any }) => void + isFooterOpen?: boolean +} + +const ConsumerGroups = (props: Props) => { + const { data = [], columns = [], onClosePopover, onSelectGroup, isFooterOpen } = props + + const { loading } = useSelector(streamGroupsSelector) + const { name: key = '' } = useSelector(selectedKeyDataSelector) ?? { } + + const [groups, setGroups] = useState([]) + const [sortedColumnName, setSortedColumnName] = useState('name') + const [sortedColumnOrder, setSortedColumnOrder] = useState(SortOrder.ASC) + + useEffect(() => { + setGroups(orderBy(data, sortedColumnName, sortedColumnOrder?.toLowerCase())) + }, [data]) + + const onChangeSorting = useCallback( + (column: any, order: SortOrder) => { + setSortedColumnName(column) + setSortedColumnOrder(order) + + setGroups(orderBy(data, column, order?.toLowerCase())) + }, [groups] + ) + + return ( + <> +
+ a + (b.minWidth ?? 0), 0)} + onWheel={onClosePopover} + onChangeSorting={onChangeSorting} + noItemsMessage={noItemsMessageString} + sortedColumn={groups?.length ? { + column: sortedColumnName, + order: sortedColumnOrder, + } : undefined} + /> +
+ + ) +} + +export default ConsumerGroups diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/index.ts b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/index.ts new file mode 100644 index 0000000000..8d78c0231e --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/index.ts @@ -0,0 +1,5 @@ +import GroupsView from './GroupsView' + +export * from './GroupsView' + +export default GroupsView diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/styles.module.scss b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/styles.module.scss new file mode 100644 index 0000000000..9f50b488fd --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/styles.module.scss @@ -0,0 +1,32 @@ +.container { + display: flex; + flex: 1; + width: 100%; + padding-top: 3px; + background-color: var(--euiColorEmptyShade); +} + +.actions, +.actionsHeader { + width: 54px; +} + +.tooltip { + min-width: 330px; +} + +.editLastId { + margin-right: 4px; +} + +.idText, .error { + display: inline-block; + color: var(--euiColorMediumShade); + font: normal normal normal 12px/18px Graphik; + margin-top: 6px; + padding-right: 6px; +} + +.error { + color: var(--euiColorDangerText); +} diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.spec.tsx new file mode 100644 index 0000000000..88ae482f68 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.spec.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { cloneDeep } from 'lodash' +import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' +import { + deleteConsumerGroups, + loadConsumerGroups, + setSelectedGroup +} from 'uiSrc/slices/browser/stream' +import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' +import { ConsumerGroupDto } from 'apiSrc/modules/browser/dto/stream.dto' +import GroupsView, { Props as GroupsViewProps } from './GroupsView' +import GroupsViewWrapper, { Props } from './GroupsViewWrapper' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('./GroupsView', () => ({ + __esModule: true, + namedExport: jest.fn(), + default: jest.fn(), +})) + +const mockGroupName = 'group' +const mockGroups: ConsumerGroupDto[] = [{ + name: 'test', + consumers: 123, + pending: 321, + smallestPendingId: '123', + greatestPendingId: '123', + lastDeliveredId: '123' +}, { + name: 'test2', + consumers: 13, + pending: 31, + smallestPendingId: '3', + greatestPendingId: '23', + lastDeliveredId: '12' +}] + +const mockGroupsView = (props: GroupsViewProps) => ( +
+ + + +
+) + +describe('GroupsViewWrapper', () => { + beforeAll(() => { + GroupsView.mockImplementation(mockGroupsView) + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render Groups container', () => { + render() + + expect(screen.getByTestId('stream-groups-container')).toBeInTheDocument() + }) + + it('should select Group', () => { + render() + + const afterRenderActions = [...store.getActions()] + + fireEvent.click(screen.getByTestId('select-group-btn')) + + expect(store.getActions()).toEqual([...afterRenderActions, setSelectedGroup(), loadConsumerGroups(false)]) + }) + + it('should delete Group', () => { + render() + + const afterRenderActions = [...store.getActions()] + + fireEvent.click(screen.getByTestId('remove-groups-button-test-icon')) + fireEvent.click(screen.getByTestId('remove-groups-button-test')) + + expect(store.getActions()).toEqual([...afterRenderActions, deleteConsumerGroups()]) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.tsx new file mode 100644 index 0000000000..60fe957678 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.tsx @@ -0,0 +1,351 @@ +import { EuiFieldText, EuiIcon, EuiText, EuiToolTip } from '@elastic/eui' +import React, { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import PopoverItemEditor from 'uiSrc/components/popover-item-editor' +import { lastDeliveredIDTooltipText } from 'uiSrc/constants/texts' +import { selectedKeyDataSelector, updateSelectedKeyRefreshTime } from 'uiSrc/slices/browser/keys' + +import { + streamGroupsSelector, + setSelectedGroup, + fetchConsumers, + setStreamViewType, + modifyLastDeliveredIdAction, + deleteConsumerGroupsAction, +} from 'uiSrc/slices/browser/stream' +import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' +import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' +import { consumerGroupIdRegex, formatLongName, validateConsumerGroupId } from 'uiSrc/utils' +import { getFormatTime } from 'uiSrc/utils/streamUtils' +import { TableCellTextAlignment } from 'uiSrc/constants' +import { StreamViewType } from 'uiSrc/slices/interfaces/stream' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import { ConsumerDto, ConsumerGroupDto, UpdateConsumerGroupDto } from 'apiSrc/modules/browser/dto/stream.dto' + +import GroupsView from './GroupsView' + +import styles from './GroupsView/styles.module.scss' + +export interface IConsumerGroup extends ConsumerGroupDto { + editing: boolean +} + +const suffix = '_stream_group' +const actionsWidth = 80 + +export interface Props { + isFooterOpen: boolean +} + +const GroupsViewWrapper = (props: Props) => { + const { + lastRefreshTime, + data: loadedGroups = [], + loading + } = useSelector(streamGroupsSelector) + const { name: selectedKey } = useSelector(selectedKeyDataSelector) ?? { name: '' } + + const dispatch = useDispatch() + + const [groups, setGroups] = useState([]) + const [deleting, setDeleting] = useState('') + const [editValue, setEditValue] = useState('') + const [idError, setIdError] = useState('') + const [isIdFocused, setIsIdFocused] = useState(false) + + const { instanceId } = useParams<{ instanceId: string }>() + + useEffect(() => { + dispatch(updateSelectedKeyRefreshTime(lastRefreshTime)) + }, [lastRefreshTime]) + + useEffect(() => { + const streamItem: IConsumerGroup[] = loadedGroups?.map((item) => ({ + ...item, + editing: false, + })) + + setGroups(streamItem) + }, [loadedGroups, deleting]) + + useEffect(() => { + if (!consumerGroupIdRegex.test(editValue)) { + setIdError('ID format is not correct') + return + } + setIdError('') + }, [editValue]) + + const closePopover = () => { + setDeleting('') + } + + const showPopover = (groupName = '') => { + setDeleting(`${groupName + suffix}`) + } + + const onSuccessDeletedGroup = () => { + sendEventTelemetry({ + event: TelemetryEvent.STREAM_CONSUMER_GROUP_DELETED, + eventData: { + databaseId: instanceId, + } + }) + closePopover() + } + + const handleDeleteGroup = (name: string) => { + dispatch(deleteConsumerGroupsAction(selectedKey, [name], onSuccessDeletedGroup)) + } + + 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 onSuccessSelectedGroup = (data: ConsumerDto[]) => { + dispatch(setStreamViewType(StreamViewType.Consumers)) + sendEventTelemetry({ + event: TelemetryEvent.STREAM_CONSUMERS_LOADED, + eventData: { + databaseId: instanceId, + length: data.length + } + }) + } + + const onSuccessApplyEditId = () => { + sendEventTelemetry({ + event: TelemetryEvent.STREAM_CONSUMER_GROUP_ID_SET, + eventData: { + databaseId: instanceId, + } + }) + } + + const handleSelectGroup = ({ rowData }: { rowData: any }) => { + dispatch(setSelectedGroup(rowData)) + dispatch(fetchConsumers( + false, + onSuccessSelectedGroup, + )) + } + + const handleApplyEditId = (groupName: string) => { + if (!!groupName.length && !idError && selectedKey) { + const data: UpdateConsumerGroupDto = { + keyName: selectedKey, + name: groupName, + lastDeliveredId: editValue + } + dispatch(modifyLastDeliveredIdAction(data, onSuccessApplyEditId)) + } + } + + const handleEditId = (name: string, lastDeliveredId: string) => { + const newGroupsState: IConsumerGroup[] = groups?.map((item) => + (item.name === name ? { ...item, editing: true } : item)) + + setGroups(newGroupsState) + setEditValue(lastDeliveredId) + } + + const columns: ITableColumn[] = [ + + { + id: 'name', + label: 'Group Name', + truncateText: true, + isSortable: true, + relativeWidth: 0.44, + minWidth: 100, + headerClassName: 'streamItemHeader', + headerCellClassName: 'truncateText', + render: function Name(_name: string, { name }: IConsumerGroup) { + // Better to cut the long string, because it could affect virtual scroll performance + const cellContent = name.substring(0, 200) + const tooltipContent = formatLongName(name) + return ( + +
+ + <>{cellContent} + +
+
+ ) + }, + }, + { + id: 'consumers', + label: 'Consumers', + minWidth: 120, + relativeWidth: 0.15, + truncateText: true, + isSortable: true, + headerClassName: 'streamItemHeader', + headerCellClassName: 'truncateText', + }, + { + id: 'pending', + label: 'Pending', + minWidth: 95, + relativeWidth: 0.12, + isSortable: true, + className: styles.cell, + headerClassName: 'streamItemHeader', + headerCellClassName: 'truncateText', + render: function P(_name: string, { pending, greatestPendingId, smallestPendingId, name }: IConsumerGroup) { + const smallestTimestamp = smallestPendingId?.split('-')?.[0] + const greatestTimestamp = greatestPendingId?.split('-')?.[0] + + const tooltipContent = `${getFormatTime(smallestTimestamp)} – ${getFormatTime(greatestTimestamp)}` + return ( + +
+ {!!pending && ( + + <>{pending} + + )} + {!pending && pending} +
+
+ ) + }, + }, + { + id: 'lastDeliveredId', + label: 'Last Delivered ID', + relativeWidth: 0.25, + minWidth: 190, + isSortable: true, + className: styles.cell, + headerClassName: 'streamItemHeader', + headerCellClassName: 'truncateText', + render: function Id(_name: string, { lastDeliveredId: id }: IConsumerGroup) { + 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, { lastDeliveredId, name, editing }: IConsumerGroup) { + const showIdError = !isIdFocused && idError + return ( +
+ handleEditId(name, lastDeliveredId)} + onApply={() => handleApplyEditId(name)} + className={styles.editLastId} + isDisabled={!editValue.length || !!idError} + isLoading={loading} + > + <> + setEditValue(validateConsumerGroupId(e.target.value))} + onBlur={() => setIsIdFocused(false)} + onFocus={() => setIsIdFocused(true)} + append={( + + + + )} + style={{ width: 240 }} + autoComplete="off" + data-testid="last-id-field" + /> + {!showIdError && Timestamp - Sequence Number or $} + {showIdError && {idError}} + + + + and all its consumers will be removed from {selectedKey} + + )} + item={name} + suffix={suffix} + deleting={deleting} + closePopover={closePopover} + updateLoading={false} + showPopover={showPopover} + testid={`remove-groups-button-${name}`} + handleDeleteItem={() => handleDeleteGroup(name)} + handleButtonClick={handleRemoveIconClick} + /> +
+ ) + }, + }, + ] + + return ( + <> + + + ) +} + +export default GroupsViewWrapper diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/index.ts b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/index.ts new file mode 100644 index 0000000000..2156925913 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/index.ts @@ -0,0 +1,3 @@ +import GroupsViewWrapper from './GroupsViewWrapper' + +export default GroupsViewWrapper diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.spec.tsx new file mode 100644 index 0000000000..4c5a3cda5d --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.spec.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' + +import MessageAckPopover, { Props } from './MessageAckPopover' + +const mockedProps = mock() + +describe('MessageAckPopover', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.tsx new file mode 100644 index 0000000000..0b50b938bd --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/MessageAckPopover.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { EuiText, EuiPopover, EuiButton } from '@elastic/eui' + +import styles from './styles.module.scss' + +export interface Props { + id: string + isOpen: boolean + closePopover: () => void + showPopover: () => void + acknowledge: (entry: string) => void +} + +const AckPopover = (props: Props) => { + const { id, isOpen, closePopover, showPopover, acknowledge } = props + return ( + + ACK + + )} + > +
+ + {id} +
+ will be acknowledged and removed from the pending messages list +
+
+ acknowledge(id)} + data-testid="acknowledge-submit" + > + Acknowledge + +
+
+
+ ) +} + +export default AckPopover diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/index.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/index.tsx new file mode 100644 index 0000000000..4bbe694b04 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/index.tsx @@ -0,0 +1,3 @@ +import MessageAckPopover from './MessageAckPopover' + +export default MessageAckPopover diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/styles.module.scss b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/styles.module.scss new file mode 100644 index 0000000000..bdb3aed509 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageAckPopover/styles.module.scss @@ -0,0 +1,36 @@ +.popoverWrapper.popoverWrapper.popoverWrapper { + border: 1px solid var(--externalLinkColor); + + :before { + border-left: 12px solid var(--externalLinkColor) !important; + } + :after { + border-left-width: 13px !important; + } +} + +:global { + .euiPanel--paddingMedium { + padding: 18px !important; + } +} + +.popover { + max-width: 564px !important; + word-wrap: break-word; +} + +.popoverFooter { + display: flex; + justify-content: flex-end; + margin-top: 12px; +} + +.ackBtn:global(.euiButton.euiButton--secondary) { + min-width: initial !important; + color: var(--textColorShade) !important; +} + +.ackBtn :global(.euiButtonContent .euiButton__text) { + font: normal normal normal 12px/18px Graphik !important; +} diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.spec.tsx new file mode 100644 index 0000000000..883ff5ea04 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.spec.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' + +import MessageClaimPopover, { Props } from './MessageClaimPopover' + +const mockedProps = mock() + +describe('MessageClaimPopover', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.tsx new file mode 100644 index 0000000000..86f33af32f --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/MessageClaimPopover.tsx @@ -0,0 +1,339 @@ +import React, { useState, useEffect, ChangeEvent } from 'react' +import { useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import { + EuiSuperSelect, + EuiSuperSelectOption, + EuiPopover, + EuiButton, + EuiForm, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldNumber, + EuiSwitch, + EuiText, + EuiCheckbox, + EuiSpacer, + EuiToolTip +} from '@elastic/eui' +import { useFormik } from 'formik' +import { orderBy, filter } from 'lodash' + +import { selectedGroupSelector, selectedConsumerSelector } from 'uiSrc/slices/browser/stream' +import { validateNumber } from 'uiSrc/utils' +import { prepareDataForClaimRequest, getDefaultConsumer, ClaimTimeOptions } from 'uiSrc/utils/streamUtils' +import { ClaimPendingEntryDto, ClaimPendingEntriesResponse, ConsumerDto } from 'apiSrc/modules/browser/dto/stream.dto' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import styles from './styles.module.scss' + +const getConsumersOptions = (consumers: ConsumerDto[]) => ( + consumers.map((consumer) => ({ + value: consumer.name, + inputDisplay: ( + + {consumer.name} + + {`pending: ${consumer.pending}`} + + + ) + })) +) + +const timeOptions: EuiSuperSelectOption[] = [ + { value: ClaimTimeOptions.RELATIVE, inputDisplay: 'Relative Time' }, + { value: ClaimTimeOptions.ABSOLUTE, inputDisplay: 'Timestamp' }, +] + +export interface Props { + id: string + isOpen: boolean + closePopover: () => void + showPopover: () => void + claimMessage: ( + data: Partial, + successAction: (data: ClaimPendingEntriesResponse) => void + ) => void + handleCancelClaim: () => void +} + +const MessageClaimPopover = (props: Props) => { + const { + id, + isOpen, + closePopover, + showPopover, + claimMessage, + handleCancelClaim + } = props + + const { + data: consumers = [], + } = useSelector(selectedGroupSelector) ?? {} + const { name: currentConsumerName, pending = 0 } = useSelector(selectedConsumerSelector) ?? { name: '' } + + const [isOptionalShow, setIsOptionalShow] = useState(false) + const [consumerOptions, setConsumerOptions] = useState[]>([]) + const [initialValues, setInitialValues] = useState({ + consumerName: '', + minIdleTime: '0', + timeCount: '0', + timeOption: ClaimTimeOptions.RELATIVE, + retryCount: '0', + force: false + }) + + const { instanceId } = useParams<{ instanceId: string }>() + + const formik = useFormik({ + initialValues, + enableReinitialize: true, + validateOnBlur: false, + onSubmit: (values) => { + const data = prepareDataForClaimRequest(values, [id], isOptionalShow) + claimMessage(data, onSuccessSubmit) + }, + }) + + const onSuccessSubmit = (data: ClaimPendingEntriesResponse) => { + sendEventTelemetry({ + event: TelemetryEvent.STREAM_CONSUMER_MESSAGE_CLAIMED, + eventData: { + databaseId: instanceId, + pending: pending - data.affected.length + } + }) + setIsOptionalShow(false) + formik.resetForm() + closePopover() + } + + const handleClosePopover = () => { + closePopover() + setIsOptionalShow(false) + formik.resetForm() + } + + const handleChangeTimeFormat = (value: ClaimTimeOptions) => { + formik.setFieldValue('timeOption', value) + if (value === ClaimTimeOptions.ABSOLUTE) { + formik.setFieldValue( + 'timeCount', + new Date().getTime() + ) + } else { + formik.setFieldValue('timeCount', '0') + } + } + + const handleCancel = () => { + handleCancelClaim() + handleClosePopover() + } + + useEffect(() => { + const consumersWithoutCurrent = filter(consumers, (consumer) => consumer.name !== currentConsumerName) + const sortedConsumers = orderBy(getConsumersOptions(consumersWithoutCurrent), ['name'], ['asc']) + if (sortedConsumers.length) { + setConsumerOptions(sortedConsumers) + setInitialValues({ + ...initialValues, + consumerName: getDefaultConsumer(consumersWithoutCurrent)?.name + }) + } + }, [consumers, currentConsumerName]) + + const button = ( + + CLAIM + + ) + + const buttonTooltip = ( + + {button} + + ) + + return ( + e.stopPropagation()} + anchorPosition="leftCenter" + ownFocus + isOpen={isOpen} + className="popover" + panelPaddingSize="m" + anchorClassName="claimPendingMessage" + panelClassName={styles.popoverWrapper} + closePopover={() => {}} + button={consumers.length < 2 ? buttonTooltip : button} + > + + + + + formik.setFieldValue('consumerName', value)} + data-testid="destination-select" + /> + + + + + ) => { + formik.setFieldValue( + e.target.name, + validateNumber(e.target.value.trim()) + ) + }} + type="text" + min={0} + /> + + + + {isOptionalShow && ( + <> + + + + + ) => { + formik.setFieldValue( + e.target.name, + validateNumber(e.target.value.trim()) + ) + }} + type="text" + min={0} + /> + + + + + + + + + + ) => { + formik.setFieldValue( + e.target.name, + validateNumber(e.target.value.trim()) + ) + }} + type="text" + min={0} + /> + + + + + ) => { + formik.setFieldValue(e.target.name, !formik.values.force) + }} + data-testid="force-claim-checkbox" + /> + + + + + )} + + + setIsOptionalShow(e.target.checked)} + className={styles.switchOption} + data-testid="optional-parameters-switcher" + compressed + /> + +
+ + Cancel + + formik.handleSubmit()} + data-testid="btn-submit" + > + Claim + +
+
+
+
+ ) +} + +export default MessageClaimPopover diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/index.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/index.tsx new file mode 100644 index 0000000000..e806b910e8 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/index.tsx @@ -0,0 +1,3 @@ +import MessageClaimPopover from './MessageClaimPopover' + +export default MessageClaimPopover diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/styles.module.scss b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/styles.module.scss new file mode 100644 index 0000000000..790a1196ed --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessageClaimPopover/styles.module.scss @@ -0,0 +1,127 @@ +.popoverWrapper.popoverWrapper.popoverWrapper { + background-color: var(--euiColorLightestShade); + border: 1px solid var(--externalLinkColor); + + :before { + border-left: 12px solid var(--externalLinkColor) !important; + } + + :after { + border-left: 13px solid var(--euiColorLightestShade) !important; + } +} + +.popoverWrapper :global(.euiFormControlLayout__append.euiFormLabel) { + position: absolute; + right: 0; + height: 36px !important; + padding: 9px 12px !important; + background-color: transparent !important; + color: var(--inputPlaceholderColor) !important; +} + +.footer { + width: 100%; + justify-content:space-between; + margin: 30px 0 7px !important; + + &:global(.euiFlexGroup--gutterLarge) > :global(.euiFlexItem) { + margin-left: 0; + } + + :global(.euiSwitch) :global(.euiSwitch__button) { + width: 30px; + } +} + +.footer .footerBtn { + height: 36px !important; + + &:last-child { + margin-left: 18px + } +} + +.claimBtn:global(.euiButton.euiButton--secondary) { + margin-left: 12px; + min-width: initial !important; + color: var(--textColorShade) !important; +} + +.claimBtn :global(.euiButtonContent .euiButton__text) { + font: normal normal normal 12px/18px Graphik !important; +} + +.option { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + height: 36px; +} + +.option .pendingCount { + font: normal normal normal 13px/24px Graphik; + letter-spacing: -0.13px; + color: var(--inputPlaceholderColor) !important; + white-space: nowrap; +} + +.consumerOption :global(.euiContextMenu__itemLayout) .pendingCount { + padding-right: 13px; +} + +.consumerOption :global(.euiContextMenu__itemLayout) { + margin-right: 20px; + height: 20px; +} + +.popoverWrapper.popoverWrapper .container .idle { + position: relative; + margin-right: 0; +} + +.popoverWrapper.popoverWrapper .container .timeSelect { + margin-left: 0; +} + +.popoverWrapper .hiddenLabel :global(.euiFormRow__label) { + font-size: 0; +} + +.popoverWrapper :global(.euiFlexGroup--gutterLarge > .euiFlexItem) { + margin: 9px; +} + +.relative { + position: relative; +} + +.consumerField { + width: 389px !important; +} + +.fieldWithAppend { + width: 162px !important; + height: 36px !important; + padding-right: 40px !important; +} + +.timeOptionField { + width: 120px !important; + height: 38px !important; +} + +.popoverWrapper .container .retryCountField { + width: 88px; + height: 36px !important; +} + +.popoverWrapper .container .grow { + flex-grow: 2; +} + +.consumerName { + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/MessagesView.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/MessagesView.spec.tsx new file mode 100644 index 0000000000..7a65c3f0b0 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/MessagesView.spec.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import { PendingEntryDto } from 'apiSrc/modules/browser/dto/stream.dto' +import MessagesView, { Props } from './MessagesView' + +const mockedProps = mock() +const mockMessages: PendingEntryDto[] = [{ + id: '123', + consumerName: 'test', + idle: 321, + delivered: 321, +}, { + id: '1234', + consumerName: 'test2', + idle: 3213, + delivered: 1321, +}] + +describe('MessagesView', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/MessagesView.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/MessagesView.tsx new file mode 100644 index 0000000000..95d4d37f3a --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/MessagesView.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import cx from 'classnames' + +import { + streamGroupsSelector, +} from 'uiSrc/slices/browser/stream' +import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' +import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' +import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { PendingEntryDto } from 'apiSrc/modules/browser/dto/stream.dto' + +import styles from './styles.module.scss' + +const headerHeight = 60 +const rowHeight = 54 + +const noItemsMessageString = 'Your Consumer has no pending messages.' + +export interface Props { + data: PendingEntryDto[] + columns: ITableColumn[] + total: number + onClosePopover: () => void + loadMoreItems: () => void + isFooterOpen?: boolean +} + +const MessagesView = (props: Props) => { + const { data = [], columns = [], total, onClosePopover, loadMoreItems, isFooterOpen } = props + + const { loading, } = useSelector(streamGroupsSelector) + const { name: key = '' } = useSelector(selectedKeyDataSelector) ?? { } + + return ( + <> +
+ a + (b.minWidth ?? 0), 0)} + onWheel={onClosePopover} + loadMoreItems={loadMoreItems} + noItemsMessage={noItemsMessageString} + /> +
+ + ) +} + +export default MessagesView diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/index.ts b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/index.ts new file mode 100644 index 0000000000..e18a4e81fa --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/index.ts @@ -0,0 +1,5 @@ +import MessagesView from './MessagesView' + +export * from './MessagesView' + +export default MessagesView diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/styles.module.scss b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/styles.module.scss new file mode 100644 index 0000000000..db3e303d9d --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesView/styles.module.scss @@ -0,0 +1,22 @@ +.container { + display: flex; + flex: 1; + width: 100%; + padding-top: 3px; + background-color: var(--euiColorEmptyShade); +} + +.actions, +.actionsHeader { + width: 54px; +} + +.actionCell { + width: 100%; + display: flex; + justify-content: flex-end; +} + +.deliveredHeaderCell { + min-width: 200px !important; +} diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesViewWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesViewWrapper.spec.tsx new file mode 100644 index 0000000000..d67cf1e3fb --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesViewWrapper.spec.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { cloneDeep } from 'lodash' +import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' +import { loadConsumerGroups, setSelectedGroup } from 'uiSrc/slices/browser/stream' +import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' +import { PendingEntryDto } from 'apiSrc/modules/browser/dto/stream.dto' +import MessagesView, { Props as MessagesViewProps } from './MessagesView' +import MessagesViewWrapper, { Props } from './MessagesViewWrapper' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('./MessagesView', () => ({ + __esModule: true, + namedExport: jest.fn(), + default: jest.fn(), +})) + +const mockMessages: PendingEntryDto[] = [{ + id: '123', + consumerName: 'test', + idle: 321, + delivered: 321, +}, { + id: '1234', + consumerName: 'test2', + idle: 3213, + delivered: 1321, +}] + +const mockMessagesView = (props: MessagesViewProps) => ( +
+ + +
+) + +describe('MessagesViewWrapper', () => { + beforeAll(() => { + MessagesView.mockImplementation(mockMessagesView) + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + // it('should render Messages container', () => { + // render() + + // expect(screen.getByTestId('stream-messages-container')).toBeInTheDocument() + // }) + + it.skip('should claim Message', () => { + render() + + const afterRenderActions = [...store.getActions()] + + fireEvent.click(screen.getByTestId('claim-message-btn')) + + expect(store.getActions()).toEqual([...afterRenderActions, setSelectedGroup(), loadConsumerGroups(false)]) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesViewWrapper.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesViewWrapper.tsx new file mode 100644 index 0000000000..f393f51309 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/MessagesViewWrapper.tsx @@ -0,0 +1,215 @@ +import { EuiText } from '@elastic/eui' +import React, { useCallback, useEffect, useState } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { useParams } from 'react-router-dom' +import { last, toNumber } from 'lodash' +import cx from 'classnames' + +import { + fetchMoreConsumerMessages, + selectedConsumerSelector, + selectedGroupSelector, + ackPendingEntriesAction, + claimPendingMessages +} from 'uiSrc/slices/browser/stream' +import { selectedKeyDataSelector, updateSelectedKeyRefreshTime } from 'uiSrc/slices/browser/keys' +import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' +import { getFormatTime, getNextId } from 'uiSrc/utils/streamUtils' +import { SortOrder } from 'uiSrc/constants' +import { + AckPendingEntriesResponse, + PendingEntryDto, + ClaimPendingEntryDto, + ClaimPendingEntriesResponse +} from 'apiSrc/modules/browser/dto/stream.dto' +import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import MessagesView from './MessagesView' +import MessageClaimPopover from './MessageClaimPopover' +import MessageAckPopover from './MessageAckPopover' + +import styles from './MessagesView/styles.module.scss' + +const actionsWidth = 150 +const minColumnWidth = 195 +const claimPrefix = '-claim' +const ackPrefix = '-ack' + +export interface Props { + isFooterOpen: boolean +} + +const MessagesViewWrapper = (props: Props) => { + const { + lastRefreshTime, + data: loadedMessages = [], + pending = 0 + } = useSelector(selectedConsumerSelector) ?? {} + const { name: group } = useSelector(selectedGroupSelector) ?? { name: '' } + const { name: key } = useSelector(selectedKeyDataSelector) ?? { name: '' } + const { instanceId } = useParams<{ instanceId: string }>() + + const [openPopover, setOpenPopover] = useState('') + + const dispatch = useDispatch() + + useEffect(() => { + dispatch(updateSelectedKeyRefreshTime(lastRefreshTime)) + }, []) + + const showAchPopover = (id: string) => { + setOpenPopover(id + ackPrefix) + } + + const closePopover = () => { + setOpenPopover('') + } + + const handleCancelClaim = () => { + sendEventTelemetry({ + event: TelemetryEvent.STREAM_CONSUMER_MESSAGE_CLAIM_CANCELED, + eventData: { + databaseId: instanceId, + pending + } + }) + } + + const showClaimPopover = (id: string) => { + setOpenPopover(id + claimPrefix) + } + + const onSuccessAck = (data :AckPendingEntriesResponse) => { + sendEventTelemetry({ + event: TelemetryEvent.STREAM_CONSUMER_MESSAGE_ACKNOWLEDGED, + eventData: { + databaseId: instanceId, + pending: pending - data.affected + } + }) + setOpenPopover('') + } + + const handleAchPendingMessage = (entry: string) => { + dispatch(ackPendingEntriesAction(key, group, [entry], onSuccessAck)) + } + + const handleClaimingId = ( + data: Partial, + onSuccess: (data: ClaimPendingEntriesResponse) => void + ) => { + dispatch(claimPendingMessages(data, onSuccess)) + } + const loadMoreItems = useCallback(() => { + const lastLoadedEntryId = last(loadedMessages)?.id ?? '-' + const nextId = `(${getNextId(lastLoadedEntryId, SortOrder.ASC)}` + + dispatch(fetchMoreConsumerMessages(SCAN_COUNT_DEFAULT, nextId)) + }, [loadedMessages]) + + const columns: ITableColumn[] = [ + { + id: 'id', + label: 'Entry ID', + absoluteWidth: minColumnWidth, + minWidth: minColumnWidth, + className: styles.cell, + headerClassName: 'streamItemHeader', + render: function Id(_name: string, { id }: PendingEntryDto) { + const timestamp = id?.split('-')?.[0] + return ( +
+ +
+ {getFormatTime(timestamp)} +
+
+ +
+ {id} +
+
+
+ ) + }, + }, + { + id: 'idle', + label: 'Last Message Delivered', + minWidth: 256, + absoluteWidth: 106, + truncateText: true, + headerClassName: 'streamItemHeader', + headerCellClassName: 'truncateText', + render: function Idle(_name: string, { id, idle }: PendingEntryDto) { + const timestamp = id?.split('-')?.[0] + return ( +
+ +
+ {getFormatTime(`${toNumber(timestamp) + idle}`)} +
+
+
+ ) + }, + }, + { + id: 'delivered', + label: 'Times Message Delivered', + minWidth: 106, + absoluteWidth: 106, + truncateText: true, + headerClassName: cx('streamItemHeader', styles.deliveredHeaderCell), + headerCellClassName: 'truncateText', + }, + { + id: 'actions', + label: '', + headerClassName: 'streamItemHeader', + className: styles.actionCell, + minWidth: actionsWidth, + absoluteWidth: actionsWidth, + render: function Actions(_act: any, { id }: PendingEntryDto) { + return ( +
+ closePopover()} + showPopover={() => showAchPopover(id)} + acknowledge={handleAchPendingMessage} + /> + closePopover()} + showPopover={() => showClaimPopover(id)} + claimMessage={handleClaimingId} + handleCancelClaim={handleCancelClaim} + /> +
+ ) + }, + }, + ] + + return ( + <> + + + ) +} + +export default MessagesViewWrapper diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/index.ts b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/index.ts new file mode 100644 index 0000000000..fd6ce1b8b1 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/messages-view/index.ts @@ -0,0 +1,5 @@ +import MessagesViewWrapper from './MessagesViewWrapper' + +export * from './MessagesViewWrapper' + +export default MessagesViewWrapper diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/StreamDetails.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/StreamDataView.spec.tsx similarity index 52% rename from redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/StreamDetails.spec.tsx rename to redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/StreamDataView.spec.tsx index 46c52c999f..0ef6e2105c 100644 --- a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/StreamDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/StreamDataView.spec.tsx @@ -1,12 +1,12 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { render } from 'uiSrc/utils/test-utils' -import StreamDetails, { Props } from './StreamDetails' +import StreamDataView, { Props } from './StreamDataView' const mockedProps = mock() -describe('StreamDetails', () => { +describe('StreamDataView', () => { it('should render', () => { - expect(render()).toBeTruthy() + expect(render()).toBeTruthy() }) }) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/StreamDataView.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/StreamDataView.tsx new file mode 100644 index 0000000000..0ebe017ec6 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/StreamDataView.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { isNull } from 'lodash' +import cx from 'classnames' + +import { + fetchStreamEntries, + streamDataSelector, + streamSelector, +} from 'uiSrc/slices/browser/stream' +import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' +import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' +import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' +import { SortOrder } from 'uiSrc/constants' +import { StreamEntryDto } from 'apiSrc/modules/browser/dto/stream.dto' + +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.' + +export interface Props { + data: StreamEntryDto[] + columns: ITableColumn[] + onClosePopover: () => void + loadMoreItems: () => void + isFooterOpen?: boolean +} + +const StreamDataView = (props: Props) => { + const { data: entries = [], columns = [], onClosePopover, loadMoreItems, isFooterOpen } = props + const dispatch = useDispatch() + + const { loading } = useSelector(streamSelector) + const { + total, + firstEntry, + lastEntry, + } = useSelector(streamDataSelector) + const { name: key } = useSelector(selectedKeyDataSelector) ?? { name: '' } + + const [sortedColumnName, setSortedColumnName] = useState('id') + const [sortedColumnOrder, setSortedColumnOrder] = useState(SortOrder.DESC) + + const onChangeSorting = (column: any, order: SortOrder) => { + setSortedColumnName(column) + setSortedColumnOrder(order) + + dispatch(fetchStreamEntries(key, SCAN_COUNT_DEFAULT, order, false)) + } + + return ( + <> + +
+ {/*
+ +
*/} + +
+ + ) +} + +export default StreamDataView diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/index.ts b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/index.ts new file mode 100644 index 0000000000..bc8c479afd --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/index.ts @@ -0,0 +1,3 @@ +import StreamDataView from './StreamDataView' + +export default StreamDataView diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/styles.module.scss b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/styles.module.scss new file mode 100644 index 0000000000..be0727e2e2 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataView/styles.module.scss @@ -0,0 +1,24 @@ +.container { + display: flex; + flex: 1; + width: 100%; + padding-top: 3px; + background-color: var(--euiColorEmptyShade); +} + +.actions, +.actionsHeader { + width: 54px; +} + +.columnManager { + z-index: 11; + position: absolute; + right: 18px; + margin-top: 20px; + width: 40px; + button { + width: 40px; + background-color: var(--euiColorEmptyShade) !important; + } +} diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataViewWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataViewWrapper.spec.tsx new file mode 100644 index 0000000000..8f44ddf34b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataViewWrapper.spec.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render, screen } from 'uiSrc/utils/test-utils' +import StreamDataViewWrapper, { Props } from './StreamDataViewWrapper' + +const mockedProps = mock() + +describe('StreamDataViewWrapper', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render Stream Data container', () => { + render() + + expect(screen.getByTestId('stream-entries-container')).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataViewWrapper.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataViewWrapper.tsx new file mode 100644 index 0000000000..7097b80e89 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/StreamDataViewWrapper.tsx @@ -0,0 +1,217 @@ +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, updateSelectedKeyRefreshTime } from 'uiSrc/slices/browser/keys' +import { StreamEntryDto } from 'apiSrc/modules/browser/dto/stream.dto' + +import StreamDataView from './StreamDataView' +import styles from './StreamDataView/styles.module.scss' + +const suffix = '_stream' +const actionsWidth = 50 +const minColumnWidth = 190 + +export interface Props { + isFooterOpen: boolean + loadMoreItems: () => void +} + +const StreamDataViewWrapper = (props: Props) => { + const { + entries: loadedEntries = [], + keyName: key, + lastRefreshTime + } = useSelector(streamDataSelector) + const { id: instanceId } = useSelector(connectedInstanceSelector) + const { viewType: browserViewType } = useSelector(keysSelector) + + const dispatch = useDispatch() + + // for Manager columns + // const [uniqFields, setUniqFields] = useState({}) + const [entries, setEntries] = useState([]) + const [columns, setColumns] = useState([]) + const [deleting, setDeleting] = useState('') + + useEffect(() => { + dispatch(updateSelectedKeyRefreshTime(lastRefreshTime)) + }, []) + + useEffect(() => { + let fields = {} + loadedEntries?.forEach((item) => { + fields = { + ...fields, + ...keyBy(Object.keys(item.fields)) + } + }) + + // for Manager columns + // setUniqFields(fields) + setEntries(loadedEntries) + 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( + browserViewType, + 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( + browserViewType, + TelemetryEvent.BROWSER_KEY_VALUE_REMOVE_CLICKED, + TelemetryEvent.TREE_VIEW_KEY_VALUE_REMOVE_CLICKED + ), + eventData: { + databaseId: instanceId, + keyType: KeyTypes.Stream + } + }) + } + + const getTemplateColumn = (label: string) : ITableColumn => ({ + id: label, + label, + minWidth: minColumnWidth, + isSortable: false, + className: styles.cell, + headerClassName: 'streamItemHeader', + 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: 'streamItemHeader', + 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 StreamDataViewWrapper diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/index.ts b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/index.ts new file mode 100644 index 0000000000..c962efae0b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/stream-data-view/index.ts @@ -0,0 +1,3 @@ +import StreamDataViewWrapper from './StreamDataViewWrapper' + +export default StreamDataViewWrapper diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/StreamTabs.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/StreamTabs.spec.tsx new file mode 100644 index 0000000000..c55fb85fd8 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/StreamTabs.spec.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' +import StreamTabs from './StreamTabs' + +describe('StreamTabs', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/StreamTabs.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/StreamTabs.tsx new file mode 100644 index 0000000000..bd77677f99 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/StreamTabs.tsx @@ -0,0 +1,93 @@ +import React, { useCallback } from 'react' +import { EuiIcon, EuiTab, EuiTabs } from '@elastic/eui' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' + +import { + streamSelector, + setStreamViewType, + fetchConsumerGroups, + streamGroupsSelector, + selectedGroupSelector, + selectedConsumerSelector, +} from 'uiSrc/slices/browser/stream' +import { StreamViewType } from 'uiSrc/slices/interfaces/stream' +import { ConsumerGroupDto } from 'apiSrc/modules/browser/dto/stream.dto' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import { streamViewTypeTabs } from '../constants' + +import styles from './styles.module.scss' + +const StreamTabs = () => { + const { viewType } = useSelector(streamSelector) + const { data: groups = [] } = useSelector(streamGroupsSelector) + const { name: selectedGroupName = '' } = useSelector(selectedGroupSelector) ?? {} + const { name: selectedConsumerName = '' } = useSelector(selectedConsumerSelector) ?? {} + + const { instanceId } = useParams<{ instanceId: string }>() + + const dispatch = useDispatch() + + const onSuccessLoadedConsumerGroups = (data: ConsumerGroupDto[]) => { + sendEventTelemetry({ + event: TelemetryEvent.STREAM_CONSUMER_GROUPS_LOADED, + eventData: { + databaseId: instanceId, + length: data.length + } + }) + } + + const onSelectedTabChanged = (id: StreamViewType) => { + if (id === StreamViewType.Groups && groups.length === 0) { + dispatch(fetchConsumerGroups( + true, + onSuccessLoadedConsumerGroups, + )) + } + dispatch(setStreamViewType(id)) + } + + const renderTabs = useCallback(() => { + const tabs = [...streamViewTypeTabs] + + if (selectedGroupName && (viewType === StreamViewType.Consumers || viewType === StreamViewType.Messages)) { + tabs.push({ + id: StreamViewType.Consumers, + label: selectedGroupName, + separator: + }) + } + + if (selectedConsumerName && viewType === StreamViewType.Messages) { + tabs.push({ + id: StreamViewType.Messages, + label: selectedConsumerName, + separator: + }) + } + + return tabs.map(({ id, label, separator = '' }) => ( + <> + {separator} + onSelectedTabChanged(id)} + key={id} + data-testid={`stream-tab-${id}`} + > + {label} + + + )) + }, [viewType, selectedGroupName, selectedConsumerName]) + + return ( + <> + {renderTabs()} + + ) +} + +export default StreamTabs diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/index.ts b/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/index.ts new file mode 100644 index 0000000000..0f966019fd --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/index.ts @@ -0,0 +1,3 @@ +import StreamTabs from './StreamTabs' + +export default StreamTabs diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/styles.module.scss b/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/styles.module.scss new file mode 100644 index 0000000000..ef08d46cb7 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/stream-details/stream-tabs/styles.module.scss @@ -0,0 +1,8 @@ +.separator { + position: relative; + margin-top: 10px; + width: 12px !important; + height: 12px !important; + margin-left: 2px; + margin-right: 2px; +} diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/styles.module.scss b/redisinsight/ui/src/pages/browser/components/stream-details/styles.module.scss similarity index 55% rename from redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/styles.module.scss rename to redisinsight/ui/src/pages/browser/components/stream-details/styles.module.scss index d285e15d18..69b636bc8b 100644 --- a/redisinsight/ui/src/pages/browser/components/stream-details/StreamDetails/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/stream-details/styles.module.scss @@ -1,14 +1,50 @@ +$cellPaddingWidth: 12px; + .container { - display: flex; - flex: 1; - width: 100%; - padding: 16px 18px; - background-color: var(--euiColorEmptyShade); + padding: 0 18px; + height: 100%; + position: relative; + + :global(.stream-details-table) { + height: calc(100% - 125px); + } :global { + .euiTabs { + .euiTab:not(:first-of-type) + .euiTab { + margin-left: 18px; + position: relative; + + &::after { + content: "\00FE3F" !important; + font-family: serif !important; + position: absolute !important; + background-color: initial; + top: 0px; + left: -2px; + color: var(--euiTextSubduedColor); + transform: rotate(90deg); + font-weight: bold; + font-size: 15px; + } + } + + .euiTab:nth-child(3), + .euiTab:nth-child(4) { + max-width: calc(50% - 140px); + } + } + .ReactVirtualized__Grid__innerScrollContainer { .ReactVirtualized__Table__rowColumn { + padding-right: 6px !important; border-right: 1px solid var(--tableDarkestBorderColor) !important; + border-left: none !important; + + > div { + min-height: 54px !important; + padding: $cellPaddingWidth !important; + } &:last-of-type, &:nth-last-of-type(2) { @@ -30,11 +66,11 @@ background-color: var(--euiColorLightestShade) !important; } - .streamEntry { + .streamItem { color: var(--inputTextColor) !important; } - .streamEntryId { + .streamItemId { color: var(--euiTextSubduedColor) !important; } } @@ -42,55 +78,21 @@ .ReactVirtualized__Table__headerRow { border: none !important; + + .streamItemHeader { + padding-right: 4px !important; + border: none !important; + + > div > div:first-of-type { + padding: 18px 0 18px $cellPaddingWidth !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 { @@ -111,3 +113,19 @@ margin-top: 2px; z-index: 1; } + +:global(.streamItem) { + color: var(--euiTextSubduedColor) !important; + white-space: normal; + max-width: 100%; + word-break: break-all; +} + +:global(.streamItemId) { + color: var(--euiColorMediumShade) !important; + display: flex; +} + +:global(.stream-entry-actions) { + margin-left: -5px; +} diff --git a/redisinsight/ui/src/pages/home/HomePage.tsx b/redisinsight/ui/src/pages/home/HomePage.tsx index 1dd98c055c..0142a9cba5 100644 --- a/redisinsight/ui/src/pages/home/HomePage.tsx +++ b/redisinsight/ui/src/pages/home/HomePage.tsx @@ -21,7 +21,7 @@ import { sendEventTelemetry, sendPageViewTelemetry, TelemetryEvent, TelemetryPag import AddDatabaseContainer from './components/AddDatabases/AddDatabasesContainer' import DatabasesList from './components/DatabasesListComponent/DatabasesListWrapper' import WelcomeComponent from './components/WelcomeComponent/WelcomeComponent' -import AddInstanceControls from './components/AddInstanceControls/AddInstanceControls' +import HomeHeader from './components/HomeHeader' import './styles.scss' import styles from './styles.module.scss' @@ -180,9 +180,9 @@ const HomePage = () => {
{(resizeRef) => ( - + - div:first-of-type { - margin-left: 0 !important; - } - & > div:last-of-type { - margin-right: 0 !important; - } - } -} - -.containerWelc { - .separator { - width: 150px; - height: 0; - border-top-width: 1px; - margin: 25px auto 20px; - } - .otherGuides { - flex-wrap: wrap; - & > div { - min-width: 20%; - &:nth-of-type(3n+1) { - a { - text-align: left; - } - } - &:nth-of-type(3n+3) { - a { - text-align: right; - } - } - } - } -} diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/DatabasesList.tsx b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/DatabasesList.tsx index d7a2f3bbfe..83e7973947 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/DatabasesList.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/DatabasesList.tsx @@ -198,14 +198,21 @@ function DatabasesList({ ) + const noSearchResultsMsg = ( +
+
No results found
+
No databases matched your search. Try reducing the criteria.
+
+ ) + return (
visible)} itemId="id" loading={loading} - message={loadingMsg} + message={instances.length ? noSearchResultsMsg : loadingMsg} columns={columns} rowProps={toggleSelectedRow} sorting={{ sort }} diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx index b4b9dd15fd..0198b393c2 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx @@ -6,7 +6,6 @@ import { EuiTextColor, EuiToolTip, } from '@elastic/eui' -import { formatDistanceToNow } from 'date-fns' import { capitalize } from 'lodash' import React, { useContext, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' @@ -29,7 +28,7 @@ import { resetKeys } from 'uiSrc/slices/browser/keys' import { PageNames, Pages, Theme } from 'uiSrc/constants' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { ThemeContext } from 'uiSrc/contexts/themeContext' -import { formatLongName, getDbIndex, Nullable, replaceSpaces } from 'uiSrc/utils' +import { formatLongName, getDbIndex, lastConnectionFormat, Nullable, replaceSpaces } from 'uiSrc/utils' import { appContextSelector, setAppContextInitialState } from 'uiSrc/slices/app/context' import { resetCliHelperSettings, resetCliSettingsAction } from 'uiSrc/slices/cli/cli-settings' import DatabaseListModules from 'uiSrc/components/database-list-modules/DatabaseListModules' @@ -182,18 +181,21 @@ const DatabasesListWrapper = ({ className={styles.tooltipColumnName} content={`${formatLongName(name)} ${getDbIndex(db)}`} > - + handleCheckConnectToInstance(e, id)} + onKeyDown={(e: React.KeyboardEvent) => handleCheckConnectToInstance(e, id)} + > handleCheckConnectToInstance(e, id)} - onKeyDown={(e: React.KeyboardEvent) => handleCheckConnectToInstance(e, id)} > {cellContent} {` ${getDbIndex(db)}`} - +
) @@ -286,10 +288,7 @@ const DatabasesListWrapper = ({ width: '170px', sortable: ({ lastConnection }) => (lastConnection ? -new Date(`${lastConnection}`) : -Infinity), - render: (date: Date) => - (date - ? `${formatDistanceToNow(new Date(date), { addSuffix: true })}` - : 'Never'), + render: (date: Date) => lastConnectionFormat(date), }, { field: 'controls', diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss index 2989679717..12c4c8a076 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss @@ -59,7 +59,7 @@ $breakpoint-l: 1400px; text-decoration: underline; &:hover { - text-decoration: none + text-decoration: none; } } @@ -107,3 +107,27 @@ $breakpoint-l: 1400px; width: 88px !important; height: 18px !important; } + +.noSearchResults { + display: flex; + + height: calc(100vh - 315px); + align-items: center; + flex-direction: column; + justify-content: center; + + @media (min-width: 768px) and (max-width: 1100px) { + height: calc(100vh - 223px); + } + + @media (min-width: 1101px) { + height: calc(100vh - 248px); + } +} + +.tableMsgTitle { + font-size: 18px; + margin-bottom: 12px; + height: 24px; + color: var(--htmlColor) !important; +} diff --git a/redisinsight/ui/src/pages/home/components/HelpLinksMenu/HelpLinksMenu.tsx b/redisinsight/ui/src/pages/home/components/HelpLinksMenu/HelpLinksMenu.tsx index 476babf389..929eebf444 100644 --- a/redisinsight/ui/src/pages/home/components/HelpLinksMenu/HelpLinksMenu.tsx +++ b/redisinsight/ui/src/pages/home/components/HelpLinksMenu/HelpLinksMenu.tsx @@ -1,5 +1,13 @@ import React, { useState } from 'react' -import { EuiContextMenuItem, EuiContextMenuPanel, EuiIcon, EuiInputPopover, EuiLink, EuiText, } from '@elastic/eui' +import { + EuiContextMenuItem, + EuiContextMenuPanel, + EuiIcon, + EuiInputPopover, + EuiLink, + EuiText, +} from '@elastic/eui' +import cx from 'classnames' import { IHelpGuide } from 'uiSrc/pages/home/constants/help-links' @@ -8,9 +16,11 @@ import styles from './styles.module.scss' export interface Props { onLinkClick?: (link: string) => void items: IHelpGuide[] + buttonText: string + emptyAnchor?: boolean } -const HelpLinksMenu = ({ onLinkClick, items }: Props) => { +const HelpLinksMenu = ({ emptyAnchor, onLinkClick, items, buttonText }: Props) => { const [isPopoverOpen, setPopover] = useState(false) const onButtonClick = () => { @@ -29,7 +39,7 @@ const HelpLinksMenu = ({ onLinkClick, items }: Props) => { } const menuItems = items?.map(({ id, url, title, primary }) => ( - + { ) return ( diff --git a/redisinsight/ui/src/pages/home/components/HelpLinksMenu/styles.module.scss b/redisinsight/ui/src/pages/home/components/HelpLinksMenu/styles.module.scss index 67bd00bdc6..9300f8cce8 100644 --- a/redisinsight/ui/src/pages/home/components/HelpLinksMenu/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/HelpLinksMenu/styles.module.scss @@ -1,4 +1,4 @@ -@import '@elastic/eui/src/global_styling/index'; +@import "@elastic/eui/src/global_styling/index"; $borderWidth: 2px; @@ -12,8 +12,17 @@ $borderWidth: 2px; border: $borderWidth solid var(--euiColorSecondary); padding: 0 20px; color: var(--inputTextColor); - font: normal normal bold 14px/17px Graphik, sans-serif; + font: normal normal bold 14px/17px Graphik, sans-serif !important; letter-spacing: -0.14px; + + &Empty { + padding-left: 0; + padding-right: 0px !important; + min-width: 105px; + border: none !important; + background-color: transparent !important; + font-weight: normal !important; + } } .buttonOpen { @@ -26,6 +35,12 @@ $borderWidth: 2px; border: $borderWidth solid var(--euiColorSecondary) !important; background-color: var(--euiPageBackgroundColor) !important; border-top-width: 0 !important; + + &Empty { + width: 138px !important; + border: 1px solid var(--separatorColor) !important; + background-color: var(--euiColorEmptyShade) !important; + } } .item { @@ -38,6 +53,10 @@ $borderWidth: 2px; background-color: var(--hoverInListColorLight) !important; text-decoration: none !important; } + &Empty { + padding-left: 12px !important; + padding-right: 6px !important; + } a { text-decoration: none !important; } @@ -47,3 +66,9 @@ $borderWidth: 2px; fill: var(--iconsDefaultColor) !important; } +.anchor { + width: 245px; + &Empty { + width: 108px; + } +} diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceControls/AddInstanceControls.spec.tsx b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.spec.tsx similarity index 74% rename from redisinsight/ui/src/pages/home/components/AddInstanceControls/AddInstanceControls.spec.tsx rename to redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.spec.tsx index c40b0e31f9..5b8613f679 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceControls/AddInstanceControls.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.spec.tsx @@ -1,7 +1,7 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { render } from 'uiSrc/utils/test-utils' -import AddInstanceControls, { Props } from './AddInstanceControls' +import HomeHeader, { Props } from './HomeHeader' const mockedProps = mock() @@ -16,8 +16,8 @@ jest.mock('uiSrc/slices/content/create-redis-buttons', () => { } }) -describe('AddInstanceControls', () => { +describe('HomeHeader', () => { it('should render', () => { - expect(render()).toBeTruthy() + expect(render()).toBeTruthy() }) }) diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceControls/AddInstanceControls.tsx b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx similarity index 75% rename from redisinsight/ui/src/pages/home/components/AddInstanceControls/AddInstanceControls.tsx rename to redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx index c024e050eb..a3aa3dbcce 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceControls/AddInstanceControls.tsx +++ b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx @@ -8,6 +8,7 @@ import { } from '@elastic/eui' import { isEmpty } from 'lodash' import { useSelector } from 'react-redux' +import cx from 'classnames' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import HelpLinksMenu from 'uiSrc/pages/home/components/HelpLinksMenu' import PromoLink from 'uiSrc/components/promo-link/PromoLink' @@ -16,6 +17,8 @@ import { contentSelector } from 'uiSrc/slices/content/create-redis-buttons' import { HELP_LINKS, IHelpGuide } from 'uiSrc/pages/home/constants/help-links' import { getPathToResource } from 'uiSrc/services/resourcesService' import { ContentCreateRedis } from 'uiSrc/slices/interfaces/content' +import { instancesSelector } from 'uiSrc/slices/instances/instances' +import SearchDatabasesList from '../SearchDatabasesList' import styles from './styles.module.scss' @@ -25,11 +28,15 @@ export interface Props { welcomePage?: boolean } -const AddInstanceControls = ({ onAddInstance, direction, welcomePage = false }: Props) => { +const CREATE_DATABASE = 'CREATE DATABASE' +const THE_GUIDES = 'THE GUIDES' + +const HomeHeader = ({ onAddInstance, direction, welcomePage = false }: Props) => { + const { theme } = useContext(ThemeContext) + const { data: instances } = useSelector(instancesSelector) + const { loading, data } = useSelector(contentSelector) const [promoData, setPromoData] = useState() const [guides, setGuides] = useState([]) - const { loading, data } = useSelector(contentSelector) - const { theme } = useContext(ThemeContext) useEffect(() => { if (loading || !data || isEmpty(data)) { @@ -68,15 +75,26 @@ const AddInstanceControls = ({ onAddInstance, direction, welcomePage = false }: } const AddInstanceBtn = () => ( - - + ADD REDIS DATABASE - + <> + + + ADD DATABASE + + + + ADD REDIS DATABASE + + ) const Guides = () => ( @@ -165,35 +183,49 @@ const AddInstanceControls = ({ onAddInstance, direction, welcomePage = false }: - +
{ !loading && !isEmpty(data) && ( <> - + {promoData && ( )} - + - + handleClickLink(HELP_LINKS[link as keyof typeof HELP_LINKS]?.event)} + /> + + + handleClickLink(HELP_LINKS[link as keyof typeof HELP_LINKS]?.event)} /> )} + {instances.length > 0 && ( + + + + )}
) } -export default AddInstanceControls +export default HomeHeader diff --git a/redisinsight/ui/src/pages/home/components/HomeHeader/index.ts b/redisinsight/ui/src/pages/home/components/HomeHeader/index.ts new file mode 100644 index 0000000000..100ca7a9a1 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/HomeHeader/index.ts @@ -0,0 +1,3 @@ +import HomeHeader from './HomeHeader' + +export default HomeHeader diff --git a/redisinsight/ui/src/pages/home/components/HomeHeader/styles.module.scss b/redisinsight/ui/src/pages/home/components/HomeHeader/styles.module.scss new file mode 100644 index 0000000000..c4fb2a4d8e --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/HomeHeader/styles.module.scss @@ -0,0 +1,153 @@ +@import "@elastic/eui/src/global_styling/index"; +.container { + height: 50px; +} + +.separator { + width: 0; + height: 64px; + border: solid 0 var(--separatorColor); + border-right-width: 1px; +} + +.addInstanceBtn { + padding-left: 10px; + padding-right: 10px; + height: 43px; + margin: 0 auto; + text-decoration: none !important; + + :global(.euiButton__text) { + font-weight: 500 !important; + } +} +.followText { + padding-top: 7px; + font-size: 12px !important; + text-align: left; + color: var(--euiTextColor) !important; + opacity: 0.7; +} + +.clearMarginFlexItem { + margin-bottom: 0 !important; +} + +.links { + font-size: 14px; + a { + color: var(--euiTextColor); + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } +} + +.containerDl { + padding: 0 18px; + @include euiBreakpoint("m", "l", "xl") { + padding: 0 12px; + } + + .links { + margin-left: 10px; + position: relative; + min-width: 235px; + top: -8px; + + :global(.euiFlexItem) { + margin-left: 0px !important; + margin-right: 12px !important; + + &:last-of-type { + margin-right: 0 !important; + } + } + } +} + +.contentDL { + @include euiBreakpoint("xs", "s") { + & > div:first-of-type { + margin-left: 0 !important; + } + & > div:last-of-type { + margin-right: 0 !important; + } + } +} + +.spacerDl { + @include euiBreakpoint("xs", "s") { + height: 6px !important; + } + @include euiBreakpoint("m", "l", "xl") { + height: 26px !important; + } +} + +.containerWelc { + .separator { + width: 150px; + height: 0; + border-top-width: 1px; + margin: 25px auto 20px; + } + .otherGuides { + flex-wrap: wrap; + & > div { + min-width: 20%; + &:nth-of-type(3n + 1) { + a { + text-align: left; + } + } + &:nth-of-type(3n + 3) { + a { + text-align: right; + } + } + } + } +} + +.searchContainer { + max-width: 320px; +} + +.separatorContainer { + display: none !important; + @media (min-width: 1300px) { + display: flex !important; + } +} + +.promo { + display: flex !important; + @media only screen and(max-width: 1100px) { + display: none !important; + } +} + +.linkGuides { + display: none !important; + @media (min-width: 1250px) { + display: flex !important; + } +} + +.fullGuides { + display: none !important; + @media only screen and(max-width: 1100px) { + display: flex !important; + } +} + +.smallGuides { + display: none !important; + @media (min-width: 1101px) and (max-width: 1251px) { + display: flex !important; + } +} diff --git a/redisinsight/ui/src/pages/home/components/SearchDatabasesList/SearchDatabasesList.spec.tsx b/redisinsight/ui/src/pages/home/components/SearchDatabasesList/SearchDatabasesList.spec.tsx new file mode 100644 index 0000000000..28227efd94 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/SearchDatabasesList/SearchDatabasesList.spec.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { cloneDeep } from 'lodash' +import { cleanup, fireEvent, mockedStore, render, screen, waitFor } from 'uiSrc/utils/test-utils' +import { loadInstancesSuccess } from 'uiSrc/slices/instances/instances' +import store, { RootState } from 'uiSrc/slices/store' +import { Instance } from 'uiSrc/slices/interfaces' +import SearchDatabasesList from './SearchDatabasesList' + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn() +})) + +let storeMock: typeof mockedStore +const instancesMock: Instance[] = [{ + id: '1', + name: 'local', + host: 'localhost', + port: 6379, + visible: true, + modules: [], + lastConnection: new Date(), +}, { + id: '2', + name: 'cloud', + host: 'cloud', + port: 6379, + visible: true, + modules: [], + lastConnection: new Date(), +}] + +beforeEach(() => { + cleanup() + storeMock = cloneDeep(mockedStore) + storeMock.clearActions() + + const state: RootState = store.getState(); + + (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => callback({ + ...state, + connections: { + ...state.connections, + instances: { + ...state.connections.instances, + } + } + })) +}) + +describe('SearchDatabasesList', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it.skip('should call loadInstancesSuccess with after typing', async () => { + const state: RootState = store.getState(); + (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => callback({ + ...state, + connections: { + ...state.connections, + instances: { + ...state.connections.instances, + data: instancesMock + } + } + })) + + const newInstancesMock = [ + ...instancesMock + ] + render() + + await waitFor(() => { + fireEvent.change( + screen.getByTestId('search-database-list'), + { target: { value: 'test' } } + ) + }) + + newInstancesMock[1].visible = false + + const expectedActions = [loadInstancesSuccess(newInstancesMock)] + expect(storeMock.getActions()).toEqual(expect.arrayContaining(expectedActions)) + }) +}) diff --git a/redisinsight/ui/src/pages/home/components/SearchDatabasesList/SearchDatabasesList.tsx b/redisinsight/ui/src/pages/home/components/SearchDatabasesList/SearchDatabasesList.tsx new file mode 100644 index 0000000000..0bb3bc89d0 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/SearchDatabasesList/SearchDatabasesList.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import { EuiFieldSearch } from '@elastic/eui' +import { useDispatch, useSelector } from 'react-redux' + +import { instancesSelector, loadInstancesSuccess } from 'uiSrc/slices/instances/instances' +import { Instance } from 'uiSrc/slices/interfaces' +import { lastConnectionFormat } from 'uiSrc/utils' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import styles from './styles.module.scss' + +export interface Props { + onAddInstance: () => void + direction: 'column' | 'row' + welcomePage?: boolean +} + +const SearchDatabasesList = () => { + const { data: instances } = useSelector(instancesSelector) + + const dispatch = useDispatch() + + const onQueryChange = (e: React.ChangeEvent) => { + const value = e?.target?.value?.toLowerCase() + + const itemsTemp = instances.map( + (item: Instance) => ({ + ...item, + visible: item.name?.toLowerCase().indexOf(value) !== -1 + || item.host?.toString()?.indexOf(value) !== -1 + || item.port?.toString()?.indexOf(value) !== -1 + || item.connectionType?.toString()?.indexOf(value) !== -1 + || item.modules?.toString()?.indexOf(value) !== -1 + || lastConnectionFormat(item.lastConnection)?.indexOf(value) !== -1 + }) + ) + + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_DATABASE_LIST_SEARCHED, + eventData: { + instancesFullCount: instances.length, + instancesSearchedCount: itemsTemp.filter(({ visible }) => (visible))?.length, + } + }) + + dispatch(loadInstancesSuccess(itemsTemp)) + } + + return ( + + ) +} + +export default SearchDatabasesList diff --git a/redisinsight/ui/src/pages/home/components/SearchDatabasesList/index.ts b/redisinsight/ui/src/pages/home/components/SearchDatabasesList/index.ts new file mode 100644 index 0000000000..ae44bab455 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/SearchDatabasesList/index.ts @@ -0,0 +1,3 @@ +import SearchDatabasesList from './SearchDatabasesList' + +export default SearchDatabasesList diff --git a/redisinsight/ui/src/pages/home/components/SearchDatabasesList/styles.module.scss b/redisinsight/ui/src/pages/home/components/SearchDatabasesList/styles.module.scss new file mode 100644 index 0000000000..1ce3ee37db --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/SearchDatabasesList/styles.module.scss @@ -0,0 +1,9 @@ +.search { + &:global(.euiFieldSearch) { + position: relative; + border-top: none !important; + border-left: none !important; + border-right: none !important; + background-color: transparent !important; + } +} diff --git a/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx b/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx index b0df27d209..051355a6cb 100644 --- a/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx +++ b/redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx @@ -10,7 +10,7 @@ import { sendPageViewTelemetry, TelemetryPageView } from 'uiSrc/telemetry' import { appAnalyticsInfoSelector } from 'uiSrc/slices/app/info' import darkLogo from 'uiSrc/assets/img/dark_logo.svg' import lightLogo from 'uiSrc/assets/img/light_logo.svg' -import AddInstanceControls from '../AddInstanceControls/AddInstanceControls' +import HomeHeader from '../HomeHeader/HomeHeader' import styles from './styles.module.scss' @@ -66,7 +66,7 @@ const Welcome = ({ onAddInstance }: Props) => { {underSubTitle}
- { // partially fix elastic resizable issue with zooming [secondPanelId]: 100 - prevSizes[firstPanelId], }) + return prevSizes }) }, []) const resetContext = () => { dispatch(setMonitorInitialState()) + dispatch(setInitialPubSubState()) dispatch(setAppContextInitialState()) dispatch(resetKeysData()) setTimeout(() => { diff --git a/redisinsight/ui/src/pages/pubSub/PubSubPage.tsx b/redisinsight/ui/src/pages/pubSub/PubSubPage.tsx new file mode 100644 index 0000000000..5ef145cf9f --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/PubSubPage.tsx @@ -0,0 +1,61 @@ +import { EuiTitle } from '@elastic/eui' +import React, { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import { appAnalyticsInfoSelector } from 'uiSrc/slices/app/info' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import InstanceHeader from 'uiSrc/components/instance-header' +import { SubscriptionType } from 'uiSrc/constants/pubSub' +import { sendPageViewTelemetry, TelemetryPageView } from 'uiSrc/telemetry' + +import { MessagesListWrapper, PublishMessage, SubscriptionPanel } from './components' + +import styles from './styles.module.scss' + +export const PUB_SUB_DEFAULT_CHANNEL = { channel: '*', type: SubscriptionType.PSubscribe } + +const PubSubPage = () => { + const { identified: analyticsIdentified } = useSelector(appAnalyticsInfoSelector) + const { name: connectedInstanceName } = useSelector(connectedInstanceSelector) + const { instanceId } = useParams<{ instanceId: string }>() + + const [isPageViewSent, setIsPageViewSent] = useState(false) + + useEffect(() => { + if (connectedInstanceName && !isPageViewSent && analyticsIdentified) { + sendPageView(instanceId) + } + }, [connectedInstanceName, isPageViewSent, analyticsIdentified]) + + const sendPageView = (instanceId: string) => { + sendPageViewTelemetry({ + name: TelemetryPageView.PUBSUB_PAGE, + databaseId: instanceId + }) + setIsPageViewSent(true) + } + + return ( + <> + +
+
+
+ +

Pub/Sub

+
+ +
+
+ +
+
+
+ +
+
+ + ) +} + +export default PubSubPage diff --git a/redisinsight/ui/src/pages/pubSub/components/index.ts b/redisinsight/ui/src/pages/pubSub/components/index.ts new file mode 100644 index 0000000000..340dd9793c --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/index.ts @@ -0,0 +1,9 @@ +import SubscriptionPanel from './subscription-panel' +import MessagesListWrapper from './messages-list' +import PublishMessage from './publish-message' + +export { + SubscriptionPanel, + MessagesListWrapper, + PublishMessage +} diff --git a/redisinsight/ui/src/pages/pubSub/components/messages-list/EmptyMessagesList/EmptyMessagesList.spec.tsx b/redisinsight/ui/src/pages/pubSub/components/messages-list/EmptyMessagesList/EmptyMessagesList.spec.tsx new file mode 100644 index 0000000000..a2a0fe6ae6 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/messages-list/EmptyMessagesList/EmptyMessagesList.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { ConnectionType } from 'uiSrc/slices/interfaces' +import { render } from 'uiSrc/utils/test-utils' + +import EmptyMessagesList from './EmptyMessagesList' + +describe('EmptyMessagesList', () => { + it('should render', () => { + expect( + render() + ).toBeTruthy() + }) + + it('should render cluster info for Cluster connection type', () => { + const { queryByTestId } = render( + + ) + + expect(queryByTestId('empty-messages-list-cluster')).toBeInTheDocument() + }) + + it(' not render cluster info for Cluster connection type', () => { + const { queryByTestId } = render( + + ) + + expect(queryByTestId('empty-messages-list-cluster')).not.toBeInTheDocument() + }) + + it('should not render cluster info for Cluster connection type', () => { + const { queryByTestId } = render( + + ) + + expect(queryByTestId('empty-messages-list-cluster')).not.toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/pubSub/components/messages-list/EmptyMessagesList/EmptyMessagesList.tsx b/redisinsight/ui/src/pages/pubSub/components/messages-list/EmptyMessagesList/EmptyMessagesList.tsx new file mode 100644 index 0000000000..4bd655f95f --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/messages-list/EmptyMessagesList/EmptyMessagesList.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { EuiIcon, EuiText } from '@elastic/eui' +import cx from 'classnames' + +import { ConnectionType } from 'uiSrc/slices/interfaces' + +import styles from './styles.module.scss' + +export interface Props { + connectionType?: ConnectionType + isSpublishNotSupported: boolean +} + +const EmptyMessagesList = ({ connectionType, isSpublishNotSupported }: Props) => ( +
+
+ No messages to display + + Subscribe to the Channel to see all the messages published to your database + + + + Running in production may decrease performance and memory available + + {(connectionType === ConnectionType.Cluster && isSpublishNotSupported) && ( + <> +
+ + {'Messages published with '} + + SPUBLISH + + {' will not appear in this channel'} + + + )} +
+
+) + +export default EmptyMessagesList diff --git a/redisinsight/ui/src/pages/pubSub/components/messages-list/EmptyMessagesList/index.ts b/redisinsight/ui/src/pages/pubSub/components/messages-list/EmptyMessagesList/index.ts new file mode 100644 index 0000000000..97b292f827 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/messages-list/EmptyMessagesList/index.ts @@ -0,0 +1,3 @@ +import EmptyMessagesList from './EmptyMessagesList' + +export default EmptyMessagesList diff --git a/redisinsight/ui/src/pages/pubSub/components/messages-list/EmptyMessagesList/styles.module.scss b/redisinsight/ui/src/pages/pubSub/components/messages-list/EmptyMessagesList/styles.module.scss new file mode 100644 index 0000000000..54f7aa011e --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/messages-list/EmptyMessagesList/styles.module.scss @@ -0,0 +1,85 @@ +@import "@elastic/eui/src/global_styling/mixins/helpers"; +@import "@elastic/eui/src/components/table/mixins"; +@import "@elastic/eui/src/global_styling/index"; + +.container { + @include euiScrollBar; + display: flex; + justify-content: center; + overflow: auto; + + width: 100%; + height: 100%; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + margin: auto; + + width: 530px; + height: 110px; + + &Cluster { + height: 184px; + } + + .title { + font-size: 16px !important; + line-height: 24px !important; + letter-spacing: 0px !important; + padding-bottom: 24px; + } + + .summary { + font-size: 13px !important; + line-height: 18px !important; + letter-spacing: -0.13px !important; + color: var(--euiColorMediumShade) !important; + padding-bottom: 18px; + } + + .alert { + font-size: 13px !important; + line-height: 18px !important; + letter-spacing: -0.13px !important; + color: var(--euiColorWarningLight) !important; + } + + .cluster { + font-size: 13px !important; + line-height: 18px !important; + letter-spacing: -0.13px !important; + color: var(--textColorShade) !important; + } + + .alertIcon { + margin-right: 6px; + margin-top: -3px; + } + + .badge { + font-size: 12px; + font-weight: 500; + line-height: 18px; + padding: 0 8px; + display: inline-block; + text-decoration: none; + border-radius: 4px; + white-space: nowrap; + vertical-align: middle; + cursor: default; + max-width: 100%; + text-align: left; + color: var(--htmlColor); + background-color: var(--separatorColor); + } + + .separator { + height: 0px; + width: 192px; + border: 1px solid var(--separatorColor); + margin: 30px 0; + } +} diff --git a/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesList/MessagesList.spec.tsx b/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesList/MessagesList.spec.tsx new file mode 100644 index 0000000000..a53b234907 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesList/MessagesList.spec.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' + +import MessagesList, { Props } from './MessagesList' + +const mockedProps = { + ...mock(), + height: 20, + width: 20 +} + +describe('MessagesList', () => { + it('should render', () => { + expect( + render() + ).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesList/MessagesList.tsx b/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesList/MessagesList.tsx new file mode 100644 index 0000000000..a2279d689f --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesList/MessagesList.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { ListChildComponentProps, ListOnScrollProps, VariableSizeList as List } from 'react-window' +import { useParams } from 'react-router-dom' +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui' + +import { getFormatDateTime } from 'uiSrc/utils' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { IMessage } from 'apiSrc/modules/pub-sub/interfaces/message.interface' + +import styles from './styles.module.scss' + +export interface Props { + items: IMessage[] + width: number + height: number +} + +const PROTRUDING_OFFSET = 2 +const MIN_ROW_HEIGHT = 30 + +const MessagesList = (props: Props) => { + const { items = [], width = 0, height = 0 } = props + + const [showAnchor, setShowAnchor] = useState(false) + const listRef = useRef(null) + const followRef = useRef(true) + const hasMountedRef = useRef(false) + const rowHeights = useRef<{ [key: number]: number }>({}) + const outerRef = useRef(null) + + const { instanceId = '' } = useParams<{ instanceId: string }>() + + useEffect(() => { + scrollToBottom() + }, []) + + useEffect(() => { + if (items.length > 0 && followRef.current) { + setTimeout(() => { + scrollToBottom() + }, 0) + } + }, [items]) + + useEffect(() => { + if (followRef.current) { + setTimeout(() => { + scrollToBottom() + }, 0) + } + }, [width, height]) + + const getRowHeight = (index: number) => ( + rowHeights.current[index] > MIN_ROW_HEIGHT ? (rowHeights.current[index] + 2) : MIN_ROW_HEIGHT + ) + + const setRowHeight = (index: number, size: number) => { + listRef.current?.resetAfterIndex(0) + rowHeights.current = { ...rowHeights.current, [index]: size } + } + + const scrollToBottom = () => { + listRef.current?.scrollToItem(items.length - 1, 'end') + requestAnimationFrame(() => { + listRef.current?.scrollToItem(items.length - 1, 'end') + }) + } + + // TODO: delete after manual tests + // const scrollToBottomReserve = () => { + // const { scrollHeight = 0, offsetHeight = 0 } = outerRef.current || {} + + // listRef.current?.scrollTo(scrollHeight - offsetHeight) + // requestAnimationFrame(() => { + // listRef.current?.scrollTo(scrollHeight - offsetHeight) + // }) + // } + + const handleAnchorClick = () => { + scrollToBottom() + } + + const handleScroll = useCallback((e: ListOnScrollProps) => { + if (!hasMountedRef.current) { + hasMountedRef.current = true + return + } + + if (e.scrollUpdateWasRequested === false) { + if (followRef.current && outerRef.current.scrollHeight !== outerRef.current.offsetHeight) { + sendEventTelemetry({ + event: TelemetryEvent.PUBSUB_AUTOSCROLL_PAUSED, + eventData: { + databaseId: instanceId + } + }) + } + followRef.current = false + setShowAnchor(true) + } + + if (!outerRef.current) { + return + } + + if (e.scrollOffset + outerRef.current.offsetHeight === outerRef.current.scrollHeight) { + if (!followRef.current && outerRef.current.scrollHeight !== outerRef.current.offsetHeight) { + sendEventTelemetry({ + event: TelemetryEvent.PUBSUB_AUTOSCROLL_RESUMED, + eventData: { + databaseId: instanceId, + } + }) + } + followRef.current = true + setShowAnchor(false) + } + }, []) + + const Row = ({ index, style }: ListChildComponentProps) => { + const rowRef = useRef(null) + + useEffect(() => { + if (rowRef.current) { + setRowHeight(index, rowRef.current?.clientHeight) + } + }, [rowRef]) + + const { channel, message, time } = items[index] + + return ( +
+
{getFormatDateTime(time)}
+
+ +
{channel}
+
+
+
{message}
+
+ ) + } + + return ( + <> + + {Row} + + {showAnchor && ( + + )} + + ) +} + +export default MessagesList diff --git a/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesList/index.ts b/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesList/index.ts new file mode 100644 index 0000000000..b8c4ff258f --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesList/index.ts @@ -0,0 +1,3 @@ +import MessagesList from './MessagesList' + +export default MessagesList diff --git a/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesList/styles.module.scss b/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesList/styles.module.scss new file mode 100644 index 0000000000..85390c795f --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesList/styles.module.scss @@ -0,0 +1,95 @@ +@import "@elastic/eui/src/global_styling/mixins/helpers"; +@import "@elastic/eui/src/components/table/mixins"; +@import "@elastic/eui/src/global_styling/index"; + +.time, +.channel, +.message { + display: inline-block !important; + font-size: 13px; + line-height: 18px; + letter-spacing: -0.13px; + padding-bottom: 10px; + vertical-align: text-top; +} + +.time { + color: var(--monitorTimeColor); + width: 150px; +} + +.channel { + color: var(--euiColorMediumShade); + width: 220px; + max-height: 26px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.channelAnchor { + max-width: 220px; + padding-right: 12px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.message { + width: calc(100% - 372px); + color: var(--htmlColor); + word-break: break-word; +} + +.header { + width: 100%; + display: flex; + height: 24px; + + .time, + .channel, + .message { + font-size: 14px; + line-height: 24px; + color: var(--htmlColor); + } +} + +.wrapperContainer { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +.listContainer { + height: 100%; + width: 100%; + padding-top: 14px; + padding-right: 6px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.listContent { + @include euiScrollBar; +} + +.anchorBtn { + position: absolute; + z-index: 10; + bottom: 10px; + right: 28px; + background-color: var(--euiColorSecondary) !important; + width: 36px !important; + height: 36px !important; + + box-shadow: 0px 3px 6px #00000099 !important; + border-radius: 18px !important; + + svg { + width: 20px; + height: 20px; + } +} diff --git a/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesListWrapper.spec.tsx b/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesListWrapper.spec.tsx new file mode 100644 index 0000000000..a5d38d4fcb --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesListWrapper.spec.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import store, { RootState } from 'uiSrc/slices/store' +import { render } from 'uiSrc/utils/test-utils' + +import MessagesListWrapper from './MessagesListWrapper' + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn() +})) + +beforeEach(() => { + const state: RootState = store.getState(); + + (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => callback({ + ...state, + pubsub: { + ...state.pubsub, + } + })) +}) + +describe('MessagesListWrapper', () => { + it('should render', () => { + expect( + render() + ).toBeTruthy() + }) + + it('should render EmptyMessagesList by default', () => { + const { queryByTestId } = render() + + expect(queryByTestId('messages-list')).not.toBeInTheDocument() + expect(queryByTestId('empty-messages-list')).toBeInTheDocument() + }) + + it('should render MessagesList if isSubscribed === true', () => { + const state: RootState = store.getState(); + + (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => callback({ + ...state, + pubsub: { + ...state.pubsub, + isSubscribed: true, + } + })) + + const { queryByTestId } = render() + + expect(queryByTestId('messages-list')).toBeInTheDocument() + expect(queryByTestId('empty-messages-list')).not.toBeInTheDocument() + }) + + it('should render MessagesList if messages.length !== 0', () => { + const state: RootState = store.getState(); + + (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => callback({ + ...state, + pubsub: { + ...state.pubsub, + messages: [{ time: 123, channel: 'channel', message: 'msg' }], + } + })) + + const { queryByTestId } = render() + + expect(queryByTestId('messages-list')).toBeInTheDocument() + expect(queryByTestId('empty-messages-list')).not.toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesListWrapper.tsx b/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesListWrapper.tsx new file mode 100644 index 0000000000..469c32e7ff --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/messages-list/MessagesListWrapper.tsx @@ -0,0 +1,59 @@ +import React, { useState, useEffect } from 'react' +import { useSelector } from 'react-redux' +import AutoSizer from 'react-virtualized-auto-sizer' + +import { connectedInstanceSelector, connectedInstanceOverviewSelector } from 'uiSrc/slices/instances/instances' +import { pubSubSelector } from 'uiSrc/slices/pubsub/pubsub' +import { isVersionHigherOrEquals } from 'uiSrc/utils' +import { CommandsVersions } from 'uiSrc/constants/commandsVersions' +import EmptyMessagesList from './EmptyMessagesList' +import MessagesList from './MessagesList' + +import styles from './MessagesList/styles.module.scss' + +const MessagesListWrapper = () => { + const { messages = [], isSubscribed } = useSelector(pubSubSelector) + const { connectionType } = useSelector(connectedInstanceSelector) + const { version } = useSelector(connectedInstanceOverviewSelector) + + const [isSpublishNotSupported, setIsSpublishNotSupported] = useState(true) + + useEffect(() => { + setIsSpublishNotSupported( + isVersionHigherOrEquals( + version, + CommandsVersions.SPUBLISH_NOT_SUPPORTED.since + ) + ) + }, [version]) + + return ( + <> + {(messages.length > 0 || isSubscribed) && ( +
+
+
Timestamp
+
Channel
+
Message
+
+
+ + {({ width, height }) => ( + + )} + +
+
+ )} + {messages.length === 0 && !isSubscribed && ( + + )} + + ) +} + +export default MessagesListWrapper diff --git a/redisinsight/ui/src/pages/pubSub/components/messages-list/index.ts b/redisinsight/ui/src/pages/pubSub/components/messages-list/index.ts new file mode 100644 index 0000000000..559c650a60 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/messages-list/index.ts @@ -0,0 +1,3 @@ +import MessagesListWrapper from './MessagesListWrapper' + +export default MessagesListWrapper diff --git a/redisinsight/ui/src/pages/pubSub/components/publish-message/PublishMessage.spec.tsx b/redisinsight/ui/src/pages/pubSub/components/publish-message/PublishMessage.spec.tsx new file mode 100644 index 0000000000..1668d85ffe --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/publish-message/PublishMessage.spec.tsx @@ -0,0 +1,29 @@ +import { fireEvent } from '@testing-library/react' +import { cloneDeep } from 'lodash' +import React from 'react' +import { publishMessage } from 'uiSrc/slices/pubsub/pubsub' +import { cleanup, clearStoreActions, mockedStore, render, screen } from 'uiSrc/utils/test-utils' + +import PublishMessage from './PublishMessage' + +let store: typeof mockedStore + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('PublishMessage', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should dispatch subscribe action after submit', () => { + render() + const expectedActions = [publishMessage()] + fireEvent.click(screen.getByTestId('publish-message-submit')) + + expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) + }) +}) diff --git a/redisinsight/ui/src/pages/pubSub/components/publish-message/PublishMessage.tsx b/redisinsight/ui/src/pages/pubSub/components/publish-message/PublishMessage.tsx new file mode 100644 index 0000000000..5855b3893e --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/publish-message/PublishMessage.tsx @@ -0,0 +1,136 @@ +import { + EuiBadge, + EuiButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiIcon +} from '@elastic/eui' +import cx from 'classnames' +import React, { ChangeEvent, FormEvent, useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import { appContextPubSub, setPubSubFieldsContext } from 'uiSrc/slices/app/context' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { ConnectionType } from 'uiSrc/slices/interfaces' +import { publishMessageAction } from 'uiSrc/slices/pubsub/pubsub' +import { ReactComponent as UserIcon } from 'uiSrc/assets/img/icons/user.svg' + +import styles from './styles.module.scss' + +const HIDE_BADGE_TIMER = 3000 + +const PublishMessage = () => { + const { channel: channelContext, message: messageContext } = useSelector(appContextPubSub) + const { connectionType } = useSelector(connectedInstanceSelector) + + const [channel, setChannel] = useState(channelContext) + const [message, setMessage] = useState(messageContext) + const [isShowBadge, setIsShowBadge] = useState(false) + const [affectedClients, setAffectedClients] = useState(0) + + const fieldsRef = useRef({ channel, message }) + const timeOutRef = useRef() + + const { instanceId } = useParams<{ instanceId: string }>() + const dispatch = useDispatch() + + useEffect(() => () => { + dispatch(setPubSubFieldsContext(fieldsRef.current)) + timeOutRef.current && clearTimeout(timeOutRef.current) + }, []) + + useEffect(() => { + fieldsRef.current = { channel, message } + }, [channel, message]) + + useEffect(() => { + if (isShowBadge) { + timeOutRef.current = setTimeout(() => { + isShowBadge && setIsShowBadge(false) + }, HIDE_BADGE_TIMER) + + return + } + + timeOutRef.current && clearTimeout(timeOutRef.current) + }, [isShowBadge]) + + const onSuccess = (affected: number) => { + setMessage('') + setAffectedClients(affected) + setIsShowBadge(true) + } + + const onFormSubmit = (event: FormEvent): void => { + event.preventDefault() + setIsShowBadge(false) + dispatch(publishMessageAction(instanceId, channel, message, onSuccess)) + } + + return ( + + + + + + ) => setChannel(e.target.value)} + autoComplete="off" + data-testid="field-channel-name" + /> + + + + + <> + ) => setMessage(e.target.value)} + autoComplete="off" + data-testid="field-message" + /> + + + {connectionType !== ConnectionType.Cluster && ( + <> + {affectedClients} + + + )} + + + + + + + + + + Publish + + + + + ) +} + +export default PublishMessage diff --git a/redisinsight/ui/src/pages/pubSub/components/publish-message/index.ts b/redisinsight/ui/src/pages/pubSub/components/publish-message/index.ts new file mode 100644 index 0000000000..9ee703c3c1 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/publish-message/index.ts @@ -0,0 +1,3 @@ +import PublishMessage from './PublishMessage' + +export default PublishMessage diff --git a/redisinsight/ui/src/pages/pubSub/components/publish-message/styles.module.scss b/redisinsight/ui/src/pages/pubSub/components/publish-message/styles.module.scss new file mode 100644 index 0000000000..f646330b08 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/publish-message/styles.module.scss @@ -0,0 +1,46 @@ +.container { + .channelWrapper { + min-width: 180px; + } + .messageWrapper { + flex-grow: 3 !important; + position: relative; + + .messageField { + &.showBadge { + padding-right: 80px; + } + } + } + + .badge { + position: absolute; + background-color: var(--pubSubClientsBadge) !important; + top: 50%; + right: 8px; + transform: translateY(-50%); + color: var(--htmlColor) !important; + opacity: 0; + pointer-events: none; + transition: opacity 250ms ease-in-out; + + &.show { + opacity: 1; + pointer-events: auto; + } + + :global(.euiBadge__text) { + display: flex; + align-items: center; + } + + .affectedClients { + margin-left: 6px; + } + + .iconUserBadge { + color: var(--htmlColor) !important; + margin-bottom: 2px; + } + } +} diff --git a/redisinsight/ui/src/pages/pubSub/components/subscription-panel/SubscriptionPanel.tsx b/redisinsight/ui/src/pages/pubSub/components/subscription-panel/SubscriptionPanel.tsx new file mode 100644 index 0000000000..7cc4fa3316 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/subscription-panel/SubscriptionPanel.tsx @@ -0,0 +1,109 @@ +import { EuiButton, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiToolTip } from '@elastic/eui' +import cx from 'classnames' +import React, { useContext } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import { Theme } from 'uiSrc/constants' +import { ThemeContext } from 'uiSrc/contexts/themeContext' +import { PUB_SUB_DEFAULT_CHANNEL } from 'uiSrc/pages/pubSub/PubSubPage' +import { clearPubSubMessages, pubSubSelector, toggleSubscribeTriggerPubSub } from 'uiSrc/slices/pubsub/pubsub' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import { ReactComponent as UserInCircle } from 'uiSrc/assets/img/icons/user_in_circle.svg' +import SubscribedIconDark from 'uiSrc/assets/img/pub-sub/subscribed.svg' +import SubscribedIconLight from 'uiSrc/assets/img/pub-sub/subscribed-lt.svg' +import NotSubscribedIconDark from 'uiSrc/assets/img/pub-sub/not-subscribed.svg' +import NotSubscribedIconLight from 'uiSrc/assets/img/pub-sub/not-subscribed-lt.svg' + +import styles from './styles.module.scss' + +const SubscriptionPanel = () => { + const { messages, isSubscribed, loading, count } = useSelector(pubSubSelector) + + const dispatch = useDispatch() + const { theme } = useContext(ThemeContext) + + const { instanceId = '' } = useParams<{ instanceId: string }>() + + const toggleSubscribe = () => { + dispatch(toggleSubscribeTriggerPubSub([PUB_SUB_DEFAULT_CHANNEL])) + } + + const onClickClear = () => { + dispatch(clearPubSubMessages()) + sendEventTelemetry({ + event: TelemetryEvent.PUBSUB_MESSAGES_CLEARED, + eventData: { + databaseId: instanceId, + messages: count + } + }) + } + + const subscribedIcon = theme === Theme.Dark ? SubscribedIconDark : SubscribedIconLight + const notSubscribedIcon = theme === Theme.Dark ? NotSubscribedIconDark : NotSubscribedIconLight + + const displayMessages = count !== 0 || isSubscribed + + return ( + + + + + + + + + You are { !isSubscribed && 'not' } subscribed + + + {displayMessages && ( + + Messages: {count} + + )} + + + + + + {!!messages.length && ( + + + + + + )} + + + { isSubscribed ? 'Unsubscribe' : 'Subscribe' } + + + + + + ) +} + +export default SubscriptionPanel diff --git a/redisinsight/ui/src/pages/pubSub/components/subscription-panel/index.ts b/redisinsight/ui/src/pages/pubSub/components/subscription-panel/index.ts new file mode 100644 index 0000000000..675861ad29 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/subscription-panel/index.ts @@ -0,0 +1,3 @@ +import SubscriptionPanel from './SubscriptionPanel' + +export default SubscriptionPanel diff --git a/redisinsight/ui/src/pages/pubSub/components/subscription-panel/styles.module.scss b/redisinsight/ui/src/pages/pubSub/components/subscription-panel/styles.module.scss new file mode 100644 index 0000000000..2aa83011fb --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/subscription-panel/styles.module.scss @@ -0,0 +1,17 @@ +.buttonSubscribe { + :global(.euiButton__text) { + font-weight: normal !important; + font-size: 12px !important; + } +} + +.iconSubscribe { + width: 18px; + height: 18px; + margin-right: 6px; + + .iconUser { + width: 18px; + height: 18px; + } +} diff --git a/redisinsight/ui/src/pages/pubSub/index.ts b/redisinsight/ui/src/pages/pubSub/index.ts new file mode 100644 index 0000000000..1995e6dc55 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/index.ts @@ -0,0 +1,3 @@ +import PubSubPage from './PubSubPage' + +export default PubSubPage diff --git a/redisinsight/ui/src/pages/pubSub/styles.module.scss b/redisinsight/ui/src/pages/pubSub/styles.module.scss new file mode 100644 index 0000000000..80f12ee5e2 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/styles.module.scss @@ -0,0 +1,44 @@ +@import "@elastic/eui/src/global_styling/mixins/helpers"; +@import "@elastic/eui/src/components/table/mixins"; +@import "@elastic/eui/src/global_styling/index"; + +.main { + margin: 0 16px 0; + height: calc(100% - 70px); + display: flex; + flex-direction: column; + + .contentPanel, + .footerPanel { + background-color: var(--euiColorEmptyShade); + } + + .contentPanel { + flex-grow: 1; + } + + .footerPanel { + margin-top: 16px; + padding: 10px 18px 28px; + } + + .header { + padding: 18px; + border-bottom: 1px solid var(--separatorColor); + } + + .title { + font-size: 16px; + margin-bottom: 12px; + } + + .tableWrapper { + width: 100%; + height: calc(100% - 125px); + padding: 18px 0 0 18px; + + :global(.ReactVirtualized__Grid) { + @include euiScrollBar; + } + } +} diff --git a/redisinsight/ui/src/plugins/pluginImport.ts b/redisinsight/ui/src/plugins/pluginImport.ts index 2cf8e03031..3bd3c06e18 100644 --- a/redisinsight/ui/src/plugins/pluginImport.ts +++ b/redisinsight/ui/src/plugins/pluginImport.ts @@ -123,6 +123,13 @@ export const prepareIframeHtml = (config) => { +