diff --git a/redisinsight/api/src/modules/workbench/providers/command-execution.provider.spec.ts b/redisinsight/api/src/modules/workbench/providers/command-execution.provider.spec.ts index 131db4b9f8..9198840aa5 100644 --- a/redisinsight/api/src/modules/workbench/providers/command-execution.provider.spec.ts +++ b/redisinsight/api/src/modules/workbench/providers/command-execution.provider.spec.ts @@ -105,17 +105,17 @@ describe('CommandExecutionProvider', () => { describe('create', () => { it('should process new entity', async () => { - repository.save.mockReturnValueOnce(mockCommandExecutionEntity); + repository.save.mockReturnValueOnce([mockCommandExecutionEntity]); encryptionService.encrypt.mockReturnValue(mockEncryptResult); - expect(await service.create(mockCommandExecutionPartial)).toEqual(new CommandExecution({ + expect(await service.createMany([mockCommandExecutionPartial])).toEqual([new CommandExecution({ ...mockCommandExecutionPartial, id: mockCommandExecutionEntity.id, createdAt: mockCommandExecutionEntity.createdAt, - })); + })]); }); it('should return full result even if size limit exceeded', async () => { - repository.save.mockReturnValueOnce(mockCommandExecutionEntity); + repository.save.mockReturnValueOnce([mockCommandExecutionEntity]); encryptionService.encrypt.mockReturnValue(mockEncryptResult); const executionResult = [new CommandExecutionResult({ @@ -123,15 +123,15 @@ describe('CommandExecutionProvider', () => { response: `${Buffer.alloc(WORKBENCH_CONFIG.maxResultSize, 'a').toString()}`, })]; - expect(await service.create({ + expect(await service.createMany([{ ...mockCommandExecutionPartial, result: executionResult, - })).toEqual(new CommandExecution({ + }])).toEqual([new CommandExecution({ ...mockCommandExecutionPartial, id: mockCommandExecutionEntity.id, createdAt: mockCommandExecutionEntity.createdAt, result: executionResult, - })); + })]); expect(encryptionService.encrypt).toHaveBeenLastCalledWith(JSON.stringify([ new CommandExecutionResult({ diff --git a/redisinsight/api/src/modules/workbench/providers/command-execution.provider.ts b/redisinsight/api/src/modules/workbench/providers/command-execution.provider.ts index 2ed766b7f3..bc6a3368b9 100644 --- a/redisinsight/api/src/modules/workbench/providers/command-execution.provider.ts +++ b/redisinsight/api/src/modules/workbench/providers/command-execution.provider.ts @@ -25,40 +25,50 @@ export class CommandExecutionProvider { ) {} /** - * Encrypt command execution and save entire entity + * Encrypt command executions and save entire entities * Should always throw and error in case when unable to encrypt for some reason - * @param commandExecution + * @param commandExecutions */ - async create(commandExecution: Partial): Promise { - const entity = plainToClass(CommandExecutionEntity, commandExecution); + async createMany(commandExecutions: Partial[]): Promise { + // todo: limit by 30 max to insert + let entities = await Promise.all(commandExecutions.map(async (commandExecution) => { + const entity = plainToClass(CommandExecutionEntity, commandExecution); + + // Do not store command execution result that exceeded limitation + if (JSON.stringify(entity.result).length > WORKBENCH_CONFIG.maxResultSize) { + entity.result = JSON.stringify([ + { + status: CommandExecutionStatus.Success, + response: ERROR_MESSAGES.WORKBENCH_RESPONSE_TOO_BIG(), + }, + ]); + } + + return this.encryptEntity(entity); + })); + + entities = await this.commandExecutionRepository.save(entities); - // Do not store command execution result that exceeded limitation - if (JSON.stringify(entity.result).length > WORKBENCH_CONFIG.maxResultSize) { - entity.result = JSON.stringify([ + const response = await Promise.all( + entities.map((entity, idx) => classToClass( + CommandExecution, { - status: CommandExecutionStatus.Success, - response: ERROR_MESSAGES.WORKBENCH_RESPONSE_TOO_BIG(), + ...entity, + command: commandExecutions[idx].command, + mode: commandExecutions[idx].mode, + result: commandExecutions[idx].result, + nodeOptions: commandExecutions[idx].nodeOptions, }, - ]); - } - - const response = await classToClass( - CommandExecution, - { - ...await this.commandExecutionRepository.save(await this.encryptEntity(entity)), - command: commandExecution.command, - mode: commandExecution.mode, - result: commandExecution.result, - nodeOptions: commandExecution.nodeOptions, - }, + )), ); // cleanup history and ignore error if any try { - await this.cleanupDatabaseHistory(entity.databaseId); + await this.cleanupDatabaseHistory(entities[0].databaseId); } catch (e) { this.logger.error('Error when trying to cleanup history after insert', e); } + return response; } diff --git a/redisinsight/api/src/modules/workbench/workbench.controller.ts b/redisinsight/api/src/modules/workbench/workbench.controller.ts index 08709379f1..6784417999 100644 --- a/redisinsight/api/src/modules/workbench/workbench.controller.ts +++ b/redisinsight/api/src/modules/workbench/workbench.controller.ts @@ -26,7 +26,7 @@ export class WorkbenchController { }, ], }) - @Post('/commands-execution') + @Post('/command-executions') @UseInterceptors(ClassSerializerInterceptor) @ApiRedisParams() async sendCommands( diff --git a/redisinsight/api/src/modules/workbench/workbench.service.spec.ts b/redisinsight/api/src/modules/workbench/workbench.service.spec.ts index b22ae1e1bd..cf5a271854 100644 --- a/redisinsight/api/src/modules/workbench/workbench.service.spec.ts +++ b/redisinsight/api/src/modules/workbench/workbench.service.spec.ts @@ -15,6 +15,7 @@ import { CommandExecutionResult } from 'src/modules/workbench/models/command-exe import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; import { BadRequestException, InternalServerErrorException } from '@nestjs/common'; import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CreateCommandExecutionsDto } from 'src/modules/workbench/dto/create-command-executions.dto'; import { WorkbenchAnalyticsService } from './services/workbench-analytics/workbench-analytics.service'; const mockClientOptions: IFindRedisClientInstanceByOptions = { @@ -31,6 +32,13 @@ const mockCreateCommandExecutionDto: CreateCommandExecutionDto = { role: ClusterNodeRole.All, mode: RunQueryMode.ASCII, }; +const mockCreateCommandExecutionsDto: CreateCommandExecutionsDto = { + commands: [ + mockCreateCommandExecutionDto.command, + mockCreateCommandExecutionDto.command, + ], + ...mockCreateCommandExecutionDto, +}; const mockCommandExecutionResults: CommandExecutionResult[] = [ new CommandExecutionResult({ @@ -43,16 +51,20 @@ const mockCommandExecutionResults: CommandExecutionResult[] = [ }, }), ]; -const mockCommandExecution: CommandExecution = new CommandExecution({ +const mockCommandExecutionToRun: CommandExecution = new CommandExecution({ ...mockCreateCommandExecutionDto, databaseId: mockStandaloneDatabaseEntity.id, +}); + +const mockCommandExecution: CommandExecution = new CommandExecution({ + ...mockCommandExecutionToRun, id: uuidv4(), createdAt: new Date(), result: mockCommandExecutionResults, }); const mockCommandExecutionProvider = () => ({ - create: jest.fn(), + createMany: jest.fn(), getList: jest.fn(), getOne: jest.fn(), delete: jest.fn(), @@ -91,13 +103,8 @@ describe('WorkbenchService', () => { describe('createCommandExecution', () => { it('should successfully execute command and save it', async () => { - workbenchCommandsExecutor.sendCommand.mockResolvedValueOnce(mockCommandExecutionResults); - commandExecutionProvider.create.mockResolvedValueOnce(mockCommandExecution); - - const result = await service.createCommandExecution(mockClientOptions, mockCreateCommandExecutionDto); - - expect(result).toBeInstanceOf(CommandExecution); - expect(result).toEqual(mockCommandExecution); + expect(await service.createCommandExecution(mockClientOptions, mockCreateCommandExecutionDto)) + .toEqual(mockCommandExecutionToRun); }); it('should save result as unsupported command message', async () => { workbenchCommandsExecutor.sendCommand.mockResolvedValueOnce(mockCommandExecutionResults); @@ -108,9 +115,7 @@ describe('WorkbenchService', () => { mode: RunQueryMode.ASCII, }; - await service.createCommandExecution(mockClientOptions, dto); - - expect(commandExecutionProvider.create).toHaveBeenCalledWith({ + expect(await service.createCommandExecution(mockClientOptions, dto)).toEqual({ ...dto, databaseId: mockClientOptions.instanceId, result: [ @@ -137,18 +142,35 @@ describe('WorkbenchService', () => { expect(e).toBeInstanceOf(BadRequestException); } }); - it('should throw an error from command execution provider (create)', async () => { - workbenchCommandsExecutor.sendCommand.mockResolvedValueOnce(mockCommandExecutionResults); - commandExecutionProvider.create.mockRejectedValueOnce(new InternalServerErrorException('db error')); + }); - const dto = { - ...mockCommandExecutionResults, - command: 'scan 0', - mode: RunQueryMode.ASCII, - }; + describe('createCommandExecutions', () => { + it('should successfully execute commands and save them', async () => { + workbenchCommandsExecutor.sendCommand.mockResolvedValueOnce( + [mockCommandExecutionResults, mockCommandExecutionResults], + ); + commandExecutionProvider.createMany.mockResolvedValueOnce([mockCommandExecution, mockCommandExecution]); + + const result = await service.createCommandExecutions(mockClientOptions, mockCreateCommandExecutionsDto); + + expect(result).toEqual([mockCommandExecution, mockCommandExecution]); + }); + it('should throw an error when command execution failed', async () => { + workbenchCommandsExecutor.sendCommand.mockRejectedValueOnce(new BadRequestException('error')); try { - await service.createCommandExecution(mockClientOptions, dto); + await service.createCommandExecutions(mockClientOptions, mockCreateCommandExecutionsDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + } + }); + it('should throw an error from command execution provider (create)', async () => { + workbenchCommandsExecutor.sendCommand.mockResolvedValueOnce([mockCommandExecutionResults]); + commandExecutionProvider.createMany.mockRejectedValueOnce(new InternalServerErrorException('db error')); + + try { + await service.createCommandExecutions(mockClientOptions, mockCreateCommandExecutionsDto); fail(); } catch (e) { expect(e).toBeInstanceOf(InternalServerErrorException); diff --git a/redisinsight/api/src/modules/workbench/workbench.service.ts b/redisinsight/api/src/modules/workbench/workbench.service.ts index 3b2ed11e7f..a0a4fae794 100644 --- a/redisinsight/api/src/modules/workbench/workbench.service.ts +++ b/redisinsight/api/src/modules/workbench/workbench.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { omit } from 'lodash'; import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; import { WorkbenchCommandsExecutor } from 'src/modules/workbench/providers/workbench-commands.executor'; import { CommandExecutionProvider } from 'src/modules/workbench/providers/command-execution.provider'; @@ -29,9 +30,9 @@ export class WorkbenchService { async createCommandExecution( clientOptions: IFindRedisClientInstanceByOptions, dto: CreateCommandExecutionDto, - ): Promise { + ): Promise> { const commandExecution: Partial = { - ...dto, + ...omit(dto, 'commands'), databaseId: clientOptions.instanceId, }; @@ -48,7 +49,7 @@ export class WorkbenchService { commandExecution.result = await this.commandsExecutor.sendCommand(clientOptions, { ...dto, command }); } - return this.commandExecutionProvider.create(commandExecution); + return commandExecution; } /** @@ -61,9 +62,15 @@ export class WorkbenchService { clientOptions: IFindRedisClientInstanceByOptions, dto: CreateCommandExecutionsDto, ): Promise { - return Promise.all( + // todo: rework to support pipeline + // prepare and execute commands + const commandExecutions = await Promise.all( dto.commands.map(async (command) => await this.createCommandExecution(clientOptions, { ...dto, command })), ); + + // save history + // todo: rework + return this.commandExecutionProvider.createMany(commandExecutions); } /** diff --git a/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss b/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss index 13e5f7f87b..e0bfcedd4a 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss +++ b/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss @@ -40,7 +40,7 @@ $marginIcon: 12px; } .titleWrapper { - width: calc(100% - 350px); + width: calc(100% - 380px); @media (min-width: $breakpoint-m) { width: calc(100% - 420px); diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx index a086bfb1b2..2ec57dbae5 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx @@ -3,7 +3,7 @@ import { useDispatch, useSelector } from 'react-redux' import { decode } from 'html-entities' import { useParams } from 'react-router-dom' import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api' -import { chunk, reverse, without } from 'lodash' +import { chunk, without } from 'lodash' import { Nullable, @@ -145,21 +145,18 @@ const WBViewWrapper = () => { ) => { const { loading, batchSize } = state const isNewCommand = () => !commandId - const [commands, ...rest] = chunk(splitMonacoValuePerLines(commandInit), batchSize > 1 ? batchSize : 1) + const commandsForExecuting = splitMonacoValuePerLines(removeMonacoComments(commandInit)) + .map((command) => removeMonacoComments(decode(command).trim())) + const [commands, ...rest] = chunk(commandsForExecuting, batchSize > 1 ? batchSize : 1) const multiCommands = rest.map((command) => getMultiCommands(command)) - const commandLine = without( - commands.map((command) => removeMonacoComments(decode(command).trim())), - '' - ) - - if (!commandLine.length || loading) { + if (!commands.length || loading) { setMultiCommands(multiCommands) return } isNewCommand() && scrollResults('start') - sendCommand(reverse(commandLine), multiCommands) + sendCommand(commands, multiCommands) } const sendCommand = ( diff --git a/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts b/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts index 9b42cb5270..8da0c28be6 100644 --- a/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts +++ b/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts @@ -250,21 +250,33 @@ describe('workbench results slice', () => { }) describe('thunks', () => { - describe('Standalone Cli command', () => { + describe('Standalone Cli commands', () => { it('call both sendWBCommandAction and sendWBCommandSuccess when response status is successed', async () => { // Arrange - const commands = ['keys *'] + const commands = ['keys *', 'set 1 1'] const commandId = `${Date.now()}` - const data = [{ - command: 'command', - databaseId: '123', - id: commandId + (commands.length - 1), - createdAt: new Date(), - result: [{ - response: 'test', - status: CommandExecutionStatus.Success - }] - }] + const data = [ + { + command: 'keys *', + databaseId: '123', + id: commandId + (commands.length - 1), + createdAt: new Date(), + result: [{ + response: 'test', + status: CommandExecutionStatus.Success + }] + }, + { + command: 'set 1 1', + databaseId: '123', + id: commandId + (commands.length - 1), + createdAt: new Date(), + result: [{ + response: 'test', + status: CommandExecutionStatus.Success + }] + } + ] const responsePayload = { data, status: 200 } apiService.post = jest.fn().mockResolvedValue(responsePayload) @@ -282,9 +294,9 @@ describe('workbench results slice', () => { it('call both sendWBCommandAction and sendWBCommandSuccess when response status is fail', async () => { // Arrange - const command = 'keys *' + const commands = ['keys *'] const commandId = `${Date.now()}` - const data = { + const data = [{ command: 'command', databaseId: '123', id: commandId, @@ -293,17 +305,17 @@ describe('workbench results slice', () => { response: 'test', status: CommandExecutionStatus.Fail }] - } + }] const responsePayload = { data, status: 200 } apiService.post = jest.fn().mockResolvedValue(responsePayload) // Act - await store.dispatch(sendWBCommandAction({ command, commandId })) + await store.dispatch(sendWBCommandAction({ commands, commandId })) // Assert const expectedActions = [ - sendWBCommand({ command, commandId }), + sendWBCommand({ commands, commandId }), sendWBCommandSuccess({ data, commandId }) ] @@ -312,7 +324,7 @@ describe('workbench results slice', () => { it('call both sendWBCommandAction and processWBCommandFailure when fetch is fail', async () => { // Arrange - const command = 'keys *' + const commands = ['keys *'] const commandId = `${Date.now()}` const errorMessage = 'Could not connect to aoeu:123, please check the connection details.' const responsePayload = { @@ -325,13 +337,13 @@ describe('workbench results slice', () => { apiService.post = jest.fn().mockRejectedValueOnce(responsePayload) // Act - await store.dispatch(sendWBCommandAction({ command, commandId })) + await store.dispatch(sendWBCommandAction({ commands, commandId })) // Assert const expectedActions = [ - sendWBCommand({ command, commandId }), + sendWBCommand({ commands, commandId }), addErrorNotification(responsePayload as AxiosError), - processWBCommandFailure({ command, error: responsePayload.response.data.message }), + processWBCommandFailure({ id: commandId, error: responsePayload.response.data.message }), ] expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) }) @@ -379,8 +391,8 @@ describe('workbench results slice', () => { it('call both sendWBCommandClusterAction and sendWBCommandSuccess when response status is fail', async () => { // Arrange - const command = 'keys *' - const data = { + const commands = ['keys *'] + const data = [{ command: 'command', databaseId: '123', id: commandId, @@ -389,17 +401,17 @@ describe('workbench results slice', () => { response: 'test', status: CommandExecutionStatus.Fail }] - } + }] const responsePayload = { data, status: 200 } apiService.post = jest.fn().mockResolvedValue(responsePayload) // Act - await store.dispatch(sendWBCommandClusterAction({ command, options, commandId })) + await store.dispatch(sendWBCommandClusterAction({ commands, options, commandId })) // Assert const expectedActions = [ - sendWBCommand({ command, commandId }), + sendWBCommand({ commands, commandId }), sendWBCommandSuccess({ data, commandId }) ] expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) @@ -407,7 +419,7 @@ describe('workbench results slice', () => { it('call both sendWBCommandClusterAction and processWBCommandFailure when fetch is fail', async () => { // Arrange - const command = 'keys *' + const commands = ['keys *'] const errorMessage = 'Could not connect to aoeu:123, please check the connection details.' const responsePayload = { response: { @@ -419,13 +431,13 @@ describe('workbench results slice', () => { apiService.post = jest.fn().mockRejectedValueOnce(responsePayload) // Act - await store.dispatch(sendWBCommandAction({ command, options, commandId })) + await store.dispatch(sendWBCommandAction({ commands, options, commandId })) // Assert const expectedActions = [ - sendWBCommand({ command, commandId }), + sendWBCommand({ commands, commandId }), addErrorNotification(responsePayload as AxiosError), - processWBCommandFailure({ command, error: responsePayload.response.data.message }), + processWBCommandFailure({ id: commandId, error: responsePayload.response.data.message }), ] expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) }) diff --git a/redisinsight/ui/src/slices/workbench/wb-results.ts b/redisinsight/ui/src/slices/workbench/wb-results.ts index 1d27749525..ef5ab6c25b 100644 --- a/redisinsight/ui/src/slices/workbench/wb-results.ts +++ b/redisinsight/ui/src/slices/workbench/wb-results.ts @@ -1,5 +1,6 @@ import { createSlice } from '@reduxjs/toolkit' import { AxiosError } from 'axios' +import { reverse } from 'lodash' import { apiService } from 'uiSrc/services' import { ApiEndpoints, EMPTY_COMMAND } from 'uiSrc/constants' import { addErrorNotification } from 'uiSrc/slices/app/notifications' @@ -205,7 +206,7 @@ export function sendWBCommandAction({ commands: string[] multiCommands?: string[] commandId?: string - mode: RunQueryMode + mode?: RunQueryMode onSuccessAction?: (multiCommands: string[]) => void onFailAction?: () => void }) { @@ -219,7 +220,7 @@ export function sendWBCommandAction({ const { data, status } = await apiService.post( getUrl( id, - ApiEndpoints.WORKBENCH_COMMANDS_EXECUTION, + ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS, ), { commands, @@ -228,7 +229,7 @@ export function sendWBCommandAction({ ) if (isStatusSuccessful(status)) { - dispatch(sendWBCommandSuccess({ commandId, data, processing: !!multiCommands?.length })) + dispatch(sendWBCommandSuccess({ commandId, data: reverse(data), processing: !!multiCommands?.length })) onSuccessAction?.(multiCommands) } @@ -256,7 +257,7 @@ export function sendWBCommandClusterAction({ options: CreateCommandExecutionDto commandId?: string multiCommands?: string[] - mode: RunQueryMode, + mode?: RunQueryMode, onSuccessAction?: (multiCommands: string[]) => void onFailAction?: () => void }) { @@ -270,7 +271,7 @@ export function sendWBCommandClusterAction({ const { data, status } = await apiService.post( getUrl( id, - ApiEndpoints.WORKBENCH_COMMANDS_EXECUTION, + ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS, ), { ...options, @@ -281,7 +282,7 @@ export function sendWBCommandClusterAction({ ) if (isStatusSuccessful(status)) { - dispatch(sendWBCommandSuccess({ commandId, data })) + dispatch(sendWBCommandSuccess({ commandId, data: reverse(data) })) onSuccessAction?.(multiCommands) }