diff --git a/redisinsight/api/src/__mocks__/rdi.ts b/redisinsight/api/src/__mocks__/rdi.ts index 7f8bae46fd..6f47a64613 100644 --- a/redisinsight/api/src/__mocks__/rdi.ts +++ b/redisinsight/api/src/__mocks__/rdi.ts @@ -8,12 +8,15 @@ import { ApiRdiClient } from 'src/modules/rdi/client/api.rdi.client'; import { RdiEntity } from 'src/modules/rdi/entities/rdi.entity'; import { EncryptionStrategy } from 'src/modules/encryption/models'; import { RdiDryRunJobDto } from 'src/modules/rdi/dto'; +import { sign } from 'jsonwebtoken'; export const mockRdiId = 'rdiId'; export const mockRdiPasswordEncrypted = 'password_ENCRYPTED'; export const mockRdiPasswordPlain = 'some pass'; +export const mockedRdiAccessToken = sign({ exp: Math.trunc(Date.now() / 1000) + 3600 }, 'test'); + export class MockRdiClient extends ApiRdiClient { constructor(metadata: RdiClientMetadata, client: any = jest.fn()) { super(metadata, client); @@ -31,6 +34,12 @@ export class MockRdiClient extends ApiRdiClient { public deploy = jest.fn(); + public startPipeline = jest.fn(); + + public stopPipeline = jest.fn(); + + public resetPipeline = jest.fn(); + public deployJob = jest.fn(); public dryRunJob = jest.fn(); diff --git a/redisinsight/api/src/constants/custom-error-codes.ts b/redisinsight/api/src/constants/custom-error-codes.ts index 6ae8e91d5d..58bc56e0a5 100644 --- a/redisinsight/api/src/constants/custom-error-codes.ts +++ b/redisinsight/api/src/constants/custom-error-codes.ts @@ -62,4 +62,7 @@ export enum CustomErrorCodes { RdiValidationError = 11_404, RdiNotFound = 11_405, RdiForbidden = 11_406, + RdiResetPipelineFailure = 11_407, + RdiStartPipelineFailure = 11_408, + RdiStopPipelineFailure = 11_409, } diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index cd07406e3e..a2f729a5a7 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -115,6 +115,9 @@ export default { AI_QUERY_MAX_TOKENS_RATE_LIMIT: 'Token count exceeds the conversation limit', RDI_DEPLOY_PIPELINE_FAILURE: 'Failed to deploy pipeline', + RDI_RESET_PIPELINE_FAILURE: 'Failed to reset pipeline', + RDI_STOP_PIPELINE_FAILURE: 'Failed to stop pipeline', + RDI_START_PIPELINE_FAILURE: 'Failed to start pipeline', RDI_TIMEOUT_ERROR: 'Encountered a timeout error while attempting to retrieve data', RDI_VALIDATION_ERROR: 'Validation error', INVALID_RDI_INSTANCE_ID: 'Invalid rdi instance id.', diff --git a/redisinsight/api/src/modules/rdi/client/api.rdi.client.spec.ts b/redisinsight/api/src/modules/rdi/client/api.rdi.client.spec.ts index b70211d35d..a6d01e55bd 100644 --- a/redisinsight/api/src/modules/rdi/client/api.rdi.client.spec.ts +++ b/redisinsight/api/src/modules/rdi/client/api.rdi.client.spec.ts @@ -12,7 +12,7 @@ import { import { sign } from 'jsonwebtoken'; import { ApiRdiClient } from './api.rdi.client'; import { RdiDyRunJobStatus, RdiPipeline, RdiStatisticsStatus } from '../models'; -import { RdiUrl, TOKEN_THRESHOLD } from '../constants'; +import { PipelineActions, RdiUrl, TOKEN_THRESHOLD } from '../constants'; const mockedAxios = axios as jest.Mocked; jest.mock('axios'); @@ -164,6 +164,90 @@ describe('ApiRdiClient', () => { }); }); + describe('startPipeline', () => { + it('should start the pipeline and poll for status', async () => { + const actionId = '123'; + const postResponse = { data: { action_id: actionId } }; + const getResponse = { + data: { + status: 'completed', + data: 'some data', + error: '', + }, + }; + + mockedAxios.post.mockResolvedValueOnce(postResponse); + mockedAxios.get.mockResolvedValueOnce(getResponse); + + const result = await client.startPipeline(); + + expect(mockedAxios.post).toHaveBeenCalledWith(RdiUrl.StartPipeline, expect.any(Object)); + expect(result).toEqual(getResponse.data.data); + }); + + it('should throw an error if start pipeline fails', async () => { + mockedAxios.post.mockRejectedValueOnce(mockRdiUnauthorizedError); + + await expect(client.startPipeline()).rejects.toThrow(mockRdiUnauthorizedError.message); + }); + }); + + describe('stopPipeline', () => { + it('should stop the pipeline and poll for status', async () => { + const actionId = '123'; + const postResponse = { data: { action_id: actionId } }; + const getResponse = { + data: { + status: 'completed', + data: 'some data', + error: '', + }, + }; + + mockedAxios.post.mockResolvedValueOnce(postResponse); + mockedAxios.get.mockResolvedValueOnce(getResponse); + + const result = await client.stopPipeline(); + + expect(mockedAxios.post).toHaveBeenCalledWith(RdiUrl.StopPipeline, expect.any(Object)); + expect(result).toEqual(getResponse.data.data); + }); + + it('should throw an error if stop pipeline fails', async () => { + mockedAxios.post.mockRejectedValueOnce(mockRdiUnauthorizedError); + + await expect(client.stopPipeline()).rejects.toThrow(mockRdiUnauthorizedError.message); + }); + }); + + describe('resetPipeline', () => { + it('should reset the pipeline and poll for status', async () => { + const actionId = '123'; + const postResponse = { data: { action_id: actionId } }; + const getResponse = { + data: { + status: 'completed', + data: 'some data', + error: '', + }, + }; + + mockedAxios.post.mockResolvedValueOnce(postResponse); + mockedAxios.get.mockResolvedValueOnce(getResponse); + + const result = await client.resetPipeline(); + + expect(mockedAxios.post).toHaveBeenCalledWith(RdiUrl.ResetPipeline, expect.any(Object)); + expect(result).toEqual(getResponse.data.data); + }); + + it('should throw an error if reset pipeline fails', async () => { + mockedAxios.post.mockRejectedValueOnce(mockRdiUnauthorizedError); + + await expect(client.resetPipeline()).rejects.toThrow(mockRdiUnauthorizedError.message); + }); + }); + describe('dryRunJob', () => { it('should call the RDI client with the correct URL and data', async () => { const mockResponse = { @@ -332,7 +416,7 @@ describe('ApiRdiClient', () => { it('should return response data on success', async () => { mockedAxios.get.mockResolvedValueOnce({ data: { status: 'completed', data: responseData } }); - const result = await client['pollActionStatus'](actionId); + const result = await client['pollActionStatus'](actionId, PipelineActions.Deploy); expect(mockedAxios.get).toHaveBeenCalledWith(`${RdiUrl.Action}/${actionId}`, { signal: undefined }); expect(result).toEqual(responseData); @@ -341,13 +425,14 @@ describe('ApiRdiClient', () => { it('should throw an error if action status is failed', async () => { mockedAxios.get.mockResolvedValueOnce({ data: { status: 'failed', error: { message: 'Test error' } } }); - await expect(client['pollActionStatus'](actionId)).rejects.toThrow('Test error'); + await expect(client['pollActionStatus'](actionId, PipelineActions.Deploy)).rejects.toThrow('Test error'); }); it('should throw an error if an error occurs during polling', async () => { mockedAxios.get.mockRejectedValueOnce(mockRdiUnauthorizedError); - await expect(client['pollActionStatus'](actionId)).rejects.toThrow(mockRdiUnauthorizedError.message); + await expect(client['pollActionStatus'](actionId, PipelineActions.Deploy)) + .rejects.toThrow(mockRdiUnauthorizedError.message); expect(mockedAxios.get).toHaveBeenCalledWith(`${RdiUrl.Action}/${actionId}`, { signal: undefined }); }); }); diff --git a/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts b/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts index 49fc9fdf83..62fb5a0b03 100644 --- a/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts +++ b/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts @@ -1,6 +1,5 @@ import axios, { AxiosInstance } from 'axios'; import { plainToClass } from 'class-transformer'; -import { decode } from 'jsonwebtoken'; import { RdiClient } from 'src/modules/rdi/client/rdi.client'; import { @@ -10,6 +9,7 @@ import { POLLING_INTERVAL, MAX_POLLING_TIME, WAIT_BEFORE_POLLING, + PipelineActions, } from 'src/modules/rdi/constants'; import { RdiDryRunJobDto, @@ -33,6 +33,9 @@ import { convertKeysToCamelCase } from 'src/utils/base.helper'; import { RdiPipelineTimeoutException } from 'src/modules/rdi/exceptions/rdi-pipeline.timeout-error.exception'; import * as https from 'https'; import { convertApiDataToRdiPipeline, convertRdiPipelineToApiPayload } from 'src/modules/rdi/utils/pipeline.util'; +import { RdiResetPipelineFailedException } from '../exceptions/rdi-reset-pipeline-failed.exception'; +import { RdiStartPipelineFailedException } from '../exceptions/rdi-start-pipeline-failed.exception'; +import { RdiStopPipelineFailedException } from '../exceptions/rdi-stop-pipeline-failed.exception'; export class ApiRdiClient extends RdiClient { protected readonly client: AxiosInstance; @@ -113,7 +116,46 @@ export class ApiRdiClient extends RdiClient { const actionId = response.data.action_id; - return await this.pollActionStatus(actionId); + return await this.pollActionStatus(actionId, PipelineActions.Deploy); + } catch (e) { + throw wrapRdiPipelineError(e); + } + } + + async stopPipeline(): Promise { + try { + const response = await this.client.post( + RdiUrl.StopPipeline, {}, + ); + const actionId = response.data.action_id; + + return await this.pollActionStatus(actionId, PipelineActions.Stop); + } catch (e) { + throw wrapRdiPipelineError(e); + } + } + + async startPipeline(): Promise { + try { + const response = await this.client.post( + RdiUrl.StartPipeline, {}, + ); + const actionId = response.data.action_id; + + return await this.pollActionStatus(actionId, PipelineActions.Start); + } catch (e) { + throw wrapRdiPipelineError(e); + } + } + + async resetPipeline(): Promise { + try { + const response = await this.client.post( + RdiUrl.ResetPipeline, {}, + ); + const actionId = response.data.action_id; + + return await this.pollActionStatus(actionId, PipelineActions.Reset); } catch (e) { throw wrapRdiPipelineError(e); } @@ -199,7 +241,7 @@ export class ApiRdiClient extends RdiClient { } } - private async pollActionStatus(actionId: string, abortSignal?: AbortSignal): Promise { + private async pollActionStatus(actionId: string, action: PipelineActions, abortSignal?: AbortSignal): Promise { await new Promise((resolve) => setTimeout(resolve, WAIT_BEFORE_POLLING)); const startTime = Date.now(); @@ -220,7 +262,18 @@ export class ApiRdiClient extends RdiClient { const { status, data, error } = response.data; if (status === 'failed') { - throw new RdiPipelineDeployFailedException(error?.message); + switch (action) { + case PipelineActions.Deploy: + throw new RdiPipelineDeployFailedException(error?.message); + case PipelineActions.Reset: + throw new RdiResetPipelineFailedException(error?.message); + case PipelineActions.Start: + throw new RdiStartPipelineFailedException(error?.message); + case PipelineActions.Stop: + throw new RdiStopPipelineFailedException(error?.message); + default: + throw new RdiPipelineDeployFailedException(error?.message); + } } if (status === 'completed') { diff --git a/redisinsight/api/src/modules/rdi/client/rdi.client.ts b/redisinsight/api/src/modules/rdi/client/rdi.client.ts index b087af0a8f..021a2f356a 100644 --- a/redisinsight/api/src/modules/rdi/client/rdi.client.ts +++ b/redisinsight/api/src/modules/rdi/client/rdi.client.ts @@ -35,6 +35,12 @@ export abstract class RdiClient { abstract deploy(pipeline: RdiPipeline): Promise; + abstract stopPipeline(): Promise; + + abstract startPipeline(): Promise; + + abstract resetPipeline(): Promise; + abstract dryRunJob(data: RdiDryRunJobDto): Promise; abstract testConnections(config: object): Promise; diff --git a/redisinsight/api/src/modules/rdi/constants/index.ts b/redisinsight/api/src/modules/rdi/constants/index.ts index 33fa11c842..fa7631170d 100644 --- a/redisinsight/api/src/modules/rdi/constants/index.ts +++ b/redisinsight/api/src/modules/rdi/constants/index.ts @@ -8,6 +8,9 @@ export enum RdiUrl { DryRunJob = 'api/v1/pipelines/jobs/dry-run', JobFunctions = '/api/v1/pipelines/jobs/functions', Deploy = 'api/v1/pipelines', + StopPipeline = 'api/v1/pipelines/stop', + StartPipeline = 'api/v1/pipelines/start', + ResetPipeline = 'api/v1/pipelines/reset', TestConnections = 'api/v1/pipelines/targets/dry-run', GetStatistics = 'api/v1/monitoring/statistics', GetPipelineStatus = 'api/v1/status', @@ -22,3 +25,10 @@ export const RDI_SYNC_INTERVAL = 5 * 60 * 1_000; // 5 min export const POLLING_INTERVAL = 1_000; export const MAX_POLLING_TIME = 2 * 60 * 1000; // 2 min export const WAIT_BEFORE_POLLING = 1_000; + +export enum PipelineActions { + Deploy = 'Deploy', + Reset = 'Reset', + Start = 'Start', + Stop = 'Stop', +} diff --git a/redisinsight/api/src/modules/rdi/exceptions/rdi-pipiline.error.handler.ts b/redisinsight/api/src/modules/rdi/exceptions/rdi-pipiline.error.handler.ts index 9ef6642978..cbfd7e99cd 100644 --- a/redisinsight/api/src/modules/rdi/exceptions/rdi-pipiline.error.handler.ts +++ b/redisinsight/api/src/modules/rdi/exceptions/rdi-pipiline.error.handler.ts @@ -7,7 +7,12 @@ import { import { RdiPipelineForbiddenException } from './rdi-pipeline.forbidden.exception'; export const parseErrorMessage = (error: AxiosError): string => { - const detail = error.response?.data?.detail; + const data = error.response?.data; + if (typeof data === 'string') { + return data; + } + + const detail = data?.detail; if (!detail) return error.message; if (typeof detail === 'string') return detail; diff --git a/redisinsight/api/src/modules/rdi/exceptions/rdi-reset-pipeline-failed.exception.spec.ts b/redisinsight/api/src/modules/rdi/exceptions/rdi-reset-pipeline-failed.exception.spec.ts new file mode 100644 index 0000000000..2af3179632 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/exceptions/rdi-reset-pipeline-failed.exception.spec.ts @@ -0,0 +1,34 @@ +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CustomErrorCodes } from 'src/constants'; +import { HttpStatus } from '@nestjs/common'; +import { RdiResetPipelineFailedException } from './rdi-reset-pipeline-failed.exception'; + +describe('RdiResetPipelineFailedException', () => { + it('should create an exception with default message and status code', () => { + const exception = new RdiResetPipelineFailedException(); + expect(exception.message).toEqual(ERROR_MESSAGES.RDI_RESET_PIPELINE_FAILURE); + expect(exception.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(exception.getResponse()).toEqual({ + message: ERROR_MESSAGES.RDI_RESET_PIPELINE_FAILURE, + statusCode: HttpStatus.BAD_REQUEST, + error: 'RdiResetPipelineFailed', + errorCode: CustomErrorCodes.RdiResetPipelineFailure, + errors: [undefined], + }); + }); + + it('should create an exception with custom message and error', () => { + const customMessage = 'Custom error message'; + const customError = 'Custom error'; + const exception = new RdiResetPipelineFailedException(customMessage, { error: customError }); + expect(exception.message).toEqual(customMessage); + expect(exception.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(exception.getResponse()).toEqual({ + message: customMessage, + statusCode: HttpStatus.BAD_REQUEST, + error: 'RdiResetPipelineFailed', + errorCode: CustomErrorCodes.RdiResetPipelineFailure, + errors: [customError], + }); + }); +}); diff --git a/redisinsight/api/src/modules/rdi/exceptions/rdi-reset-pipeline-failed.exception.ts b/redisinsight/api/src/modules/rdi/exceptions/rdi-reset-pipeline-failed.exception.ts new file mode 100644 index 0000000000..138c22b291 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/exceptions/rdi-reset-pipeline-failed.exception.ts @@ -0,0 +1,20 @@ +import { HttpException, HttpExceptionOptions, HttpStatus } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CustomErrorCodes } from 'src/constants'; + +export class RdiResetPipelineFailedException extends HttpException { + constructor( + message = ERROR_MESSAGES.RDI_RESET_PIPELINE_FAILURE, + options?: HttpExceptionOptions & { error?: string }, + ) { + const response = { + message, + statusCode: HttpStatus.BAD_REQUEST, + error: 'RdiResetPipelineFailed', + errorCode: CustomErrorCodes.RdiResetPipelineFailure, + errors: [options?.error], + }; + + super(response, response.statusCode, options); + } +} diff --git a/redisinsight/api/src/modules/rdi/exceptions/rdi-start-pipeline-failed.exception.spec.ts b/redisinsight/api/src/modules/rdi/exceptions/rdi-start-pipeline-failed.exception.spec.ts new file mode 100644 index 0000000000..a45396341d --- /dev/null +++ b/redisinsight/api/src/modules/rdi/exceptions/rdi-start-pipeline-failed.exception.spec.ts @@ -0,0 +1,34 @@ +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CustomErrorCodes } from 'src/constants'; +import { HttpStatus } from '@nestjs/common'; +import { RdiStartPipelineFailedException } from './rdi-start-pipeline-failed.exception'; + +describe('RdiStartPipelineFailedException', () => { + it('should create an exception with default message and status code', () => { + const exception = new RdiStartPipelineFailedException(); + expect(exception.message).toEqual(ERROR_MESSAGES.RDI_START_PIPELINE_FAILURE); + expect(exception.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(exception.getResponse()).toEqual({ + message: ERROR_MESSAGES.RDI_START_PIPELINE_FAILURE, + statusCode: HttpStatus.BAD_REQUEST, + error: 'RdiStartPipelineFailed', + errorCode: CustomErrorCodes.RdiStartPipelineFailure, + errors: [undefined], + }); + }); + + it('should create an exception with custom message and error', () => { + const customMessage = 'Custom error message'; + const customError = 'Custom error'; + const exception = new RdiStartPipelineFailedException(customMessage, { error: customError }); + expect(exception.message).toEqual(customMessage); + expect(exception.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(exception.getResponse()).toEqual({ + message: customMessage, + statusCode: HttpStatus.BAD_REQUEST, + error: 'RdiStartPipelineFailed', + errorCode: CustomErrorCodes.RdiStartPipelineFailure, + errors: [customError], + }); + }); +}); diff --git a/redisinsight/api/src/modules/rdi/exceptions/rdi-start-pipeline-failed.exception.ts b/redisinsight/api/src/modules/rdi/exceptions/rdi-start-pipeline-failed.exception.ts new file mode 100644 index 0000000000..3118c1fe1d --- /dev/null +++ b/redisinsight/api/src/modules/rdi/exceptions/rdi-start-pipeline-failed.exception.ts @@ -0,0 +1,20 @@ +import { HttpException, HttpExceptionOptions, HttpStatus } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CustomErrorCodes } from 'src/constants'; + +export class RdiStartPipelineFailedException extends HttpException { + constructor( + message = ERROR_MESSAGES.RDI_START_PIPELINE_FAILURE, + options?: HttpExceptionOptions & { error?: string }, + ) { + const response = { + message, + statusCode: HttpStatus.BAD_REQUEST, + error: 'RdiStartPipelineFailed', + errorCode: CustomErrorCodes.RdiStartPipelineFailure, + errors: [options?.error], + }; + + super(response, response.statusCode, options); + } +} diff --git a/redisinsight/api/src/modules/rdi/exceptions/rdi-stop-pipeline-failed.exception.spec.ts b/redisinsight/api/src/modules/rdi/exceptions/rdi-stop-pipeline-failed.exception.spec.ts new file mode 100644 index 0000000000..6fee2a1f17 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/exceptions/rdi-stop-pipeline-failed.exception.spec.ts @@ -0,0 +1,34 @@ +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CustomErrorCodes } from 'src/constants'; +import { HttpStatus } from '@nestjs/common'; +import { RdiStopPipelineFailedException } from './rdi-stop-pipeline-failed.exception'; + +describe('RdiStopPipelineFailedException', () => { + it('should create an exception with default message and status code', () => { + const exception = new RdiStopPipelineFailedException(); + expect(exception.message).toEqual(ERROR_MESSAGES.RDI_STOP_PIPELINE_FAILURE); + expect(exception.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(exception.getResponse()).toEqual({ + message: ERROR_MESSAGES.RDI_STOP_PIPELINE_FAILURE, + statusCode: HttpStatus.BAD_REQUEST, + error: 'RdiStopPipelineFailed', + errorCode: CustomErrorCodes.RdiStopPipelineFailure, + errors: [undefined], + }); + }); + + it('should create an exception with custom message and error', () => { + const customMessage = 'Custom error message'; + const customError = 'Custom error'; + const exception = new RdiStopPipelineFailedException(customMessage, { error: customError }); + expect(exception.message).toEqual(customMessage); + expect(exception.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(exception.getResponse()).toEqual({ + message: customMessage, + statusCode: HttpStatus.BAD_REQUEST, + error: 'RdiStopPipelineFailed', + errorCode: CustomErrorCodes.RdiStopPipelineFailure, + errors: [customError], + }); + }); +}); diff --git a/redisinsight/api/src/modules/rdi/exceptions/rdi-stop-pipeline-failed.exception.ts b/redisinsight/api/src/modules/rdi/exceptions/rdi-stop-pipeline-failed.exception.ts new file mode 100644 index 0000000000..cd0a84a96b --- /dev/null +++ b/redisinsight/api/src/modules/rdi/exceptions/rdi-stop-pipeline-failed.exception.ts @@ -0,0 +1,20 @@ +import { HttpException, HttpExceptionOptions, HttpStatus } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CustomErrorCodes } from 'src/constants'; + +export class RdiStopPipelineFailedException extends HttpException { + constructor( + message = ERROR_MESSAGES.RDI_STOP_PIPELINE_FAILURE, + options?: HttpExceptionOptions & { error?: string }, + ) { + const response = { + message, + statusCode: HttpStatus.BAD_REQUEST, + error: 'RdiStopPipelineFailed', + errorCode: CustomErrorCodes.RdiStopPipelineFailure, + errors: [options?.error], + }; + + super(response, response.statusCode, options); + } +} diff --git a/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts b/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts index 2b4706d74b..575f36a7b4 100644 --- a/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts +++ b/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts @@ -66,6 +66,39 @@ export class RdiPipelineController { return this.rdiPipelineService.deploy(rdiClientMetadata, dto); } + @Post('/stop') + @ApiEndpoint({ + description: 'Stops running pipeline', + responses: [{ status: 200 }], + }) + async stopPipeline( + @RequestRdiClientMetadata() rdiClientMetadata: RdiClientMetadata, + ): Promise { + return this.rdiPipelineService.stopPipeline(rdiClientMetadata); + } + + @Post('/start') + @ApiEndpoint({ + description: 'Starts the stopped pipeline', + responses: [{ status: 200 }], + }) + async startPipeline( + @RequestRdiClientMetadata() rdiClientMetadata: RdiClientMetadata, + ): Promise { + return this.rdiPipelineService.startPipeline(rdiClientMetadata); + } + + @Post('/reset') + @ApiEndpoint({ + description: 'Resets default pipeline', + responses: [{ status: 200 }], + }) + async resetPipeline( + @RequestRdiClientMetadata() rdiClientMetadata: RdiClientMetadata, + ): Promise { + return this.rdiPipelineService.resetPipeline(rdiClientMetadata); + } + @Post('/test-connections') @ApiEndpoint({ description: 'Test target connections', diff --git a/redisinsight/api/src/modules/rdi/rdi-pipeline.service.spec.ts b/redisinsight/api/src/modules/rdi/rdi-pipeline.service.spec.ts index 4c0602dd5f..2690e278f9 100644 --- a/redisinsight/api/src/modules/rdi/rdi-pipeline.service.spec.ts +++ b/redisinsight/api/src/modules/rdi/rdi-pipeline.service.spec.ts @@ -197,6 +197,75 @@ describe('RdiPipelineService', () => { }); }); + describe('startPipeline', () => { + it('should call getOrCreate on rdiClientProvider with the correct metadata', async () => { + const client = { + startPipeline: jest.fn(), + }; + rdiClientProvider.getOrCreate.mockResolvedValue(client); + + await service.startPipeline(rdiClientMetadata); + + expect(rdiClientProvider.getOrCreate).toHaveBeenCalledWith(rdiClientMetadata); + expect(client.startPipeline).toHaveBeenCalled(); + }); + + it('should throw an error if startPipeline fails', async () => { + const error = new Error('Start Pipeline failed'); + const client = generateMockRdiClient(rdiClientMetadata); + rdiClientProvider.getOrCreate.mockResolvedValue(client); + client.startPipeline.mockRejectedValueOnce(error); + + await expect(service.startPipeline(rdiClientMetadata)).rejects.toThrow(error); + }); + }); + + describe('stopPipeline', () => { + it('should call getOrCreate on rdiClientProvider with the correct metadata', async () => { + const client = { + stopPipeline: jest.fn(), + }; + rdiClientProvider.getOrCreate.mockResolvedValue(client); + + await service.stopPipeline(rdiClientMetadata); + + expect(rdiClientProvider.getOrCreate).toHaveBeenCalledWith(rdiClientMetadata); + expect(client.stopPipeline).toHaveBeenCalled(); + }); + + it('should throw an error if stopPipeline fails', async () => { + const error = new Error('Stop Pipeline failed'); + const client = generateMockRdiClient(rdiClientMetadata); + rdiClientProvider.getOrCreate.mockResolvedValue(client); + client.stopPipeline.mockRejectedValueOnce(error); + + await expect(service.stopPipeline(rdiClientMetadata)).rejects.toThrow(error); + }); + }); + + describe('resetPipeline', () => { + it('should call getOrCreate on rdiClientProvider with the correct metadata', async () => { + const client = { + resetPipeline: jest.fn(), + }; + rdiClientProvider.getOrCreate.mockResolvedValue(client); + + await service.resetPipeline(rdiClientMetadata); + + expect(rdiClientProvider.getOrCreate).toHaveBeenCalledWith(rdiClientMetadata); + expect(client.resetPipeline).toHaveBeenCalled(); + }); + + it('should throw an error if resetPipeline fails', async () => { + const error = new Error('Stop Pipeline failed'); + const client = generateMockRdiClient(rdiClientMetadata); + rdiClientProvider.getOrCreate.mockResolvedValue(client); + client.resetPipeline.mockRejectedValueOnce(error); + + await expect(service.resetPipeline(rdiClientMetadata)).rejects.toThrow(error); + }); + }); + describe('testConnections', () => { it('should call getOrCreate on rdiClientProvider with the correct metadata', async () => { const config = { data: 'some data' }; diff --git a/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts b/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts index 1ad18e80fa..f2db755624 100644 --- a/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts +++ b/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts @@ -70,6 +70,27 @@ export class RdiPipelineService { } } + async stopPipeline(rdiClientMetadata: RdiClientMetadata): Promise { + this.logger.log('Stopping running pipeline'); + const client = await this.rdiClientProvider.getOrCreate(rdiClientMetadata); + + return await client.stopPipeline(); + } + + async startPipeline(rdiClientMetadata: RdiClientMetadata): Promise { + this.logger.log('Starting stopped pipeline'); + const client = await this.rdiClientProvider.getOrCreate(rdiClientMetadata); + + return await client.startPipeline(); + } + + async resetPipeline(rdiClientMetadata: RdiClientMetadata): Promise { + this.logger.log('Resetting default pipeline'); + const client = await this.rdiClientProvider.getOrCreate(rdiClientMetadata); + + return await client.resetPipeline(); + } + async testConnections(rdiClientMetadata: RdiClientMetadata, config: object): Promise { this.logger.log('Trying to test connections'); diff --git a/redisinsight/api/test/api/rdi/pipeline/POST-rdi-id-pipeline-reset.test.ts b/redisinsight/api/test/api/rdi/pipeline/POST-rdi-id-pipeline-reset.test.ts new file mode 100644 index 0000000000..0dfa9c8f53 --- /dev/null +++ b/redisinsight/api/test/api/rdi/pipeline/POST-rdi-id-pipeline-reset.test.ts @@ -0,0 +1,139 @@ +import { RdiUrl } from 'src/modules/rdi/constants'; +import { sign } from 'jsonwebtoken'; +import { + describe, expect, deps, getMainCheckFn, +} from '../../deps'; +import { nock } from '../../../helpers/test'; +import { CustomErrorCodes } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { mockedAccessToken } from 'src/__mocks__'; + +const { + localDb, request, server, constants, +} = deps; + +const testRdiId = 'someTEST_pipeline_reset'; +const notExistedRdiId = 'notExisted'; +const testRdiUrl = 'http://rdilocal.test'; +const errorMessage = 'Authorization failed'; + +const endpoint = (id) => request(server).post(`/${constants.API.RDI}/${id || testRdiId}/pipeline/reset`); + +const mockResponse = { + action_id: 'some_action_id_123', +}; +const mockActionResponse = { + status: 'completed', + data: 'Some successful data', + error: null, +}; +const mockErrorMessage = 'Error when resetting a pipeline' + +const mainCheckFn = getMainCheckFn(endpoint); + +describe('POST /rdi/:id/pipeline/reset', () => { + [ + { + name: 'Should be success if rdi with :id is in db and all client resetPipeline and action calls are success', + statusCode: 201, + checkFn: ({ body }) => { + expect(body).to.eql({}); + }, + before: async () => { + await localDb.generateRdis({ id: testRdiId, url: testRdiUrl }, 1); + nock(testRdiUrl).post(`/${RdiUrl.Login}`).query(true).reply(200, { + access_token: mockedAccessToken, + }); + nock(testRdiUrl).post(`/${RdiUrl.ResetPipeline}`).query(true).reply(200, mockResponse); + nock(testRdiUrl).get(`/${RdiUrl.Action}/${mockResponse.action_id}`) + .query(true) + .reply(200, mockActionResponse); + }, + }, + { + name: 'Should throw an error if rdi is ok but the reset pipeline Action call responds success data with failed status', + statusCode: 400, + checkFn: ({ body }) => { + expect(body).to.eql({ + error: 'RdiResetPipelineFailed', + errorCode: CustomErrorCodes.RdiResetPipelineFailure, + errors: [null], + message: ERROR_MESSAGES.RDI_RESET_PIPELINE_FAILURE, + statusCode: 400, + }); + }, + before: async () => { + await localDb.generateRdis({ id: testRdiId, url: testRdiUrl }, 1); + nock(testRdiUrl).post(`/${RdiUrl.Login}`).query(true).reply(200, { + access_token: mockedAccessToken, + }); + nock(testRdiUrl).post(`/${RdiUrl.ResetPipeline}`).query(true).reply(200, mockResponse); + nock(testRdiUrl).get(`/${RdiUrl.Action}/${mockResponse.action_id}`).query(true).reply(200, { + status: 'failed', + data: null, + error: mockErrorMessage, + }); + }, + }, + { + name: 'Should throw notFoundError if rdi with id in params does not exist', + endpoint: () => endpoint(notExistedRdiId), + statusCode: 404, + checkFn: ({ body }) => { + expect(body).to.eql({ + error: 'Not Found', + message: 'Invalid rdi instance id.', + statusCode: 404, + }); + }, + before: async () => { + expect(await localDb.getRdiById(notExistedRdiId)).to.eql(null); + }, + }, + { + name: 'Should throw error if client resetPipeline will not succeed', + statusCode: 401, + checkFn: ({ body }) => { + expect(body).to.eql({ + error: 'RdiUnauthorized', + errorCode: 11402, + message: errorMessage, + statusCode: 401, + }); + }, + before: async () => { + await localDb.generateRdis({ id: testRdiId, url: testRdiUrl }, 1); + nock(testRdiUrl).post(`/${RdiUrl.Login}`).query(true).reply(200, { + access_token: mockedAccessToken, + }); + nock(testRdiUrl).post(`/${RdiUrl.ResetPipeline}`).query(true).reply(401, { + message: errorMessage, + detail: errorMessage, + }); + }, + }, + { + name: 'Should throw error if client authorization will not succeed', + statusCode: 401, + checkFn: ({ body }) => { + expect(body).to.eql({ + error: 'RdiUnauthorized', + errorCode: 11402, + message: errorMessage, + statusCode: 401, + }); + }, + before: async () => { + await localDb.generateRdis({ id: testRdiId, url: testRdiUrl }, 1); + nock(testRdiUrl).post(`/${RdiUrl.Login}`).query(true).reply(200, { + access_token: mockedAccessToken, + }); + nock(testRdiUrl).post(`/${RdiUrl.ResetPipeline}`).query(true).reply(200, mockResponse); + nock(testRdiUrl).get(`/${RdiUrl.Action}/${mockResponse.action_id}`).query(true).reply(401, { + message: 'Authorization 2 failed', + detail: errorMessage + }); + }, + }, + ].forEach(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/rdi/pipeline/POST-rdi-id-pipeline-start.test.ts b/redisinsight/api/test/api/rdi/pipeline/POST-rdi-id-pipeline-start.test.ts new file mode 100644 index 0000000000..cefbd4f21b --- /dev/null +++ b/redisinsight/api/test/api/rdi/pipeline/POST-rdi-id-pipeline-start.test.ts @@ -0,0 +1,139 @@ +import { RdiUrl } from 'src/modules/rdi/constants'; +import { sign } from 'jsonwebtoken'; +import { + describe, expect, deps, getMainCheckFn, +} from '../../deps'; +import { nock } from '../../../helpers/test'; +import { CustomErrorCodes } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { mockedAccessToken } from 'src/__mocks__'; + +const { + localDb, request, server, constants, +} = deps; + +const testRdiId = 'someTEST_pipeline_start'; +const notExistedRdiId = 'notExisted'; +const testRdiUrl = 'http://rdilocal.test'; +const errorMessage = 'Authorization failed'; + +const endpoint = (id) => request(server).post(`/${constants.API.RDI}/${id || testRdiId}/pipeline/start`); + +const mockResponse = { + action_id: 'some_action_id_123', +}; +const mockActionResponse = { + status: 'completed', + data: 'Some successful data', + error: null, +}; +const mockErrorMessage = 'Error when starting a pipeline' + +const mainCheckFn = getMainCheckFn(endpoint); + +describe('POST /rdi/:id/pipeline/start', () => { + [ + { + name: 'Should be success if rdi with :id is in db and all client startPipeline and action calls are success', + statusCode: 201, + checkFn: ({ body }) => { + expect(body).to.eql({}); + }, + before: async () => { + await localDb.generateRdis({ id: testRdiId, url: testRdiUrl }, 1); + nock(testRdiUrl).post(`/${RdiUrl.Login}`).query(true).reply(200, { + access_token: mockedAccessToken, + }); + nock(testRdiUrl).post(`/${RdiUrl.StartPipeline}`).query(true).reply(200, mockResponse); + nock(testRdiUrl).get(`/${RdiUrl.Action}/${mockResponse.action_id}`) + .query(true) + .reply(200, mockActionResponse); + }, + }, + { + name: 'Should throw an error if rdi is ok but the start pipeline Action call responds success data with failed status', + statusCode: 400, + checkFn: ({ body }) => { + expect(body).to.eql({ + error: 'RdiStartPipelineFailed', + errorCode: CustomErrorCodes.RdiStartPipelineFailure, + errors: [null], + message: ERROR_MESSAGES.RDI_START_PIPELINE_FAILURE, + statusCode: 400, + }); + }, + before: async () => { + await localDb.generateRdis({ id: testRdiId, url: testRdiUrl }, 1); + nock(testRdiUrl).post(`/${RdiUrl.Login}`).query(true).reply(200, { + access_token: mockedAccessToken, + }); + nock(testRdiUrl).post(`/${RdiUrl.StartPipeline}`).query(true).reply(200, mockResponse); + nock(testRdiUrl).get(`/${RdiUrl.Action}/${mockResponse.action_id}`).query(true).reply(200, { + status: 'failed', + data: null, + error: mockErrorMessage, + }); + }, + }, + { + name: 'Should throw notFoundError if rdi with id in params does not exist', + endpoint: () => endpoint(notExistedRdiId), + statusCode: 404, + checkFn: ({ body }) => { + expect(body).to.eql({ + error: 'Not Found', + message: 'Invalid rdi instance id.', + statusCode: 404, + }); + }, + before: async () => { + expect(await localDb.getRdiById(notExistedRdiId)).to.eql(null); + }, + }, + { + name: 'Should throw error if client startPipeline will not succeed', + statusCode: 401, + checkFn: ({ body }) => { + expect(body).to.eql({ + error: 'RdiUnauthorized', + errorCode: 11402, + message: errorMessage, + statusCode: 401, + }); + }, + before: async () => { + await localDb.generateRdis({ id: testRdiId, url: testRdiUrl }, 1); + nock(testRdiUrl).post(`/${RdiUrl.Login}`).query(true).reply(200, { + access_token: mockedAccessToken, + }); + nock(testRdiUrl).post(`/${RdiUrl.StartPipeline}`).query(true).reply(401, { + message: errorMessage, + detail: errorMessage, + }); + }, + }, + { + name: 'Should throw error if client authorization will not succeed', + statusCode: 401, + checkFn: ({ body }) => { + expect(body).to.eql({ + error: 'RdiUnauthorized', + errorCode: 11402, + message: errorMessage, + statusCode: 401, + }); + }, + before: async () => { + await localDb.generateRdis({ id: testRdiId, url: testRdiUrl }, 1); + nock(testRdiUrl).post(`/${RdiUrl.Login}`).query(true).reply(200, { + access_token: mockedAccessToken, + }); + nock(testRdiUrl).post(`/${RdiUrl.StartPipeline}`).query(true).reply(200, mockResponse); + nock(testRdiUrl).get(`/${RdiUrl.Action}/${mockResponse.action_id}`).query(true).reply(401, { + message: 'Authorization 2 failed', + detail: errorMessage + }); + }, + }, + ].forEach(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/rdi/pipeline/POST-rdi-id-pipeline-stop.test.ts b/redisinsight/api/test/api/rdi/pipeline/POST-rdi-id-pipeline-stop.test.ts new file mode 100644 index 0000000000..ab711a6d42 --- /dev/null +++ b/redisinsight/api/test/api/rdi/pipeline/POST-rdi-id-pipeline-stop.test.ts @@ -0,0 +1,139 @@ +import { RdiUrl } from 'src/modules/rdi/constants'; +import { sign } from 'jsonwebtoken'; +import { + describe, expect, deps, getMainCheckFn, +} from '../../deps'; +import { nock } from '../../../helpers/test'; +import { CustomErrorCodes } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { mockedAccessToken } from 'src/__mocks__'; + +const { + localDb, request, server, constants, +} = deps; + +const testRdiId = 'someTEST_pipeline_stop'; +const notExistedRdiId = 'notExisted'; +const testRdiUrl = 'http://rdilocal.test'; +const errorMessage = 'Authorization failed'; + +const endpoint = (id) => request(server).post(`/${constants.API.RDI}/${id || testRdiId}/pipeline/stop`); + +const mockResponse = { + action_id: 'some_action_id_123', +}; +const mockActionResponse = { + status: 'completed', + data: 'Some successful data', + error: null, +}; +const mockErrorMessage = 'Error when stopping a pipeline' + +const mainCheckFn = getMainCheckFn(endpoint); + +describe('POST /rdi/:id/pipeline/stop', () => { + [ + { + name: 'Should be success if rdi with :id is in db and all client stopPipeline and action calls are success', + statusCode: 201, + checkFn: ({ body }) => { + expect(body).to.eql({}); + }, + before: async () => { + await localDb.generateRdis({ id: testRdiId, url: testRdiUrl }, 1); + nock(testRdiUrl).post(`/${RdiUrl.Login}`).query(true).reply(200, { + access_token: mockedAccessToken, + }); + nock(testRdiUrl).post(`/${RdiUrl.StopPipeline}`).query(true).reply(200, mockResponse); + nock(testRdiUrl).get(`/${RdiUrl.Action}/${mockResponse.action_id}`) + .query(true) + .reply(200, mockActionResponse); + }, + }, + { + name: 'Should throw an error if rdi is ok but the stop pipeline Action call responds success data with failed status', + statusCode: 400, + checkFn: ({ body }) => { + expect(body).to.eql({ + error: 'RdiStopPipelineFailed', + errorCode: CustomErrorCodes.RdiStopPipelineFailure, + errors: [null], + message: ERROR_MESSAGES.RDI_STOP_PIPELINE_FAILURE, + statusCode: 400, + }); + }, + before: async () => { + await localDb.generateRdis({ id: testRdiId, url: testRdiUrl }, 1); + nock(testRdiUrl).post(`/${RdiUrl.Login}`).query(true).reply(200, { + access_token: mockedAccessToken, + }); + nock(testRdiUrl).post(`/${RdiUrl.StopPipeline}`).query(true).reply(200, mockResponse); + nock(testRdiUrl).get(`/${RdiUrl.Action}/${mockResponse.action_id}`).query(true).reply(200, { + status: 'failed', + data: null, + error: mockErrorMessage, + }); + }, + }, + { + name: 'Should throw notFoundError if rdi with id in params does not exist', + endpoint: () => endpoint(notExistedRdiId), + statusCode: 404, + checkFn: ({ body }) => { + expect(body).to.eql({ + error: 'Not Found', + message: 'Invalid rdi instance id.', + statusCode: 404, + }); + }, + before: async () => { + expect(await localDb.getRdiById(notExistedRdiId)).to.eql(null); + }, + }, + { + name: 'Should throw error if client stopPipeline will not succeed', + statusCode: 401, + checkFn: ({ body }) => { + expect(body).to.eql({ + error: 'RdiUnauthorized', + errorCode: 11402, + message: errorMessage, + statusCode: 401, + }); + }, + before: async () => { + await localDb.generateRdis({ id: testRdiId, url: testRdiUrl }, 1); + nock(testRdiUrl).post(`/${RdiUrl.Login}`).query(true).reply(200, { + access_token: mockedAccessToken, + }); + nock(testRdiUrl).post(`/${RdiUrl.StopPipeline}`).query(true).reply(401, { + message: errorMessage, + detail: errorMessage, + }); + }, + }, + { + name: 'Should throw error if client authorization will not succeed', + statusCode: 401, + checkFn: ({ body }) => { + expect(body).to.eql({ + error: 'RdiUnauthorized', + errorCode: 11402, + message: errorMessage, + statusCode: 401, + }); + }, + before: async () => { + await localDb.generateRdis({ id: testRdiId, url: testRdiUrl }, 1); + nock(testRdiUrl).post(`/${RdiUrl.Login}`).query(true).reply(200, { + access_token: mockedAccessToken, + }); + nock(testRdiUrl).post(`/${RdiUrl.StopPipeline}`).query(true).reply(200, mockResponse); + nock(testRdiUrl).get(`/${RdiUrl.Action}/${mockResponse.action_id}`).query(true).reply(401, { + message: 'Authorization 2 failed', + detail: errorMessage + }); + }, + }, + ].forEach(mainCheckFn); +}); diff --git a/redisinsight/ui/src/assets/img/rdi/download.svg b/redisinsight/ui/src/assets/img/rdi/download.svg new file mode 100644 index 0000000000..eb0bf03389 --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/download.svg @@ -0,0 +1,4 @@ + + + + diff --git a/redisinsight/ui/src/assets/img/rdi/pipelineStatuses/initial_sync.svg b/redisinsight/ui/src/assets/img/rdi/pipelineStatuses/initial_sync.svg new file mode 100644 index 0000000000..21037d78a6 --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/pipelineStatuses/initial_sync.svg @@ -0,0 +1,4 @@ + + + + diff --git a/redisinsight/ui/src/assets/img/rdi/pipelineStatuses/not_running.svg b/redisinsight/ui/src/assets/img/rdi/pipelineStatuses/not_running.svg new file mode 100644 index 0000000000..fe06d41b98 --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/pipelineStatuses/not_running.svg @@ -0,0 +1,4 @@ + + + + diff --git a/redisinsight/ui/src/assets/img/rdi/pipelineStatuses/status_error.svg b/redisinsight/ui/src/assets/img/rdi/pipelineStatuses/status_error.svg new file mode 100644 index 0000000000..1716998f9f --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/pipelineStatuses/status_error.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/redisinsight/ui/src/assets/img/rdi/pipelineStatuses/streaming.svg b/redisinsight/ui/src/assets/img/rdi/pipelineStatuses/streaming.svg new file mode 100644 index 0000000000..3e2b200e21 --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/pipelineStatuses/streaming.svg @@ -0,0 +1,4 @@ + + + + diff --git a/redisinsight/ui/src/assets/img/rdi/playFilled.svg b/redisinsight/ui/src/assets/img/rdi/playFilled.svg new file mode 100644 index 0000000000..3e6f8c7b0d --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/playFilled.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/assets/img/rdi/reset.svg b/redisinsight/ui/src/assets/img/rdi/reset.svg new file mode 100644 index 0000000000..87911554ce --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/reset.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/rdi/save.svg b/redisinsight/ui/src/assets/img/rdi/save.svg new file mode 100644 index 0000000000..51d687c357 --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/save.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/assets/img/rdi/stopFilled.svg b/redisinsight/ui/src/assets/img/rdi/stopFilled.svg new file mode 100644 index 0000000000..b216c691c6 --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/stopFilled.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/assets/img/rdi/three_dots.svg b/redisinsight/ui/src/assets/img/rdi/three_dots.svg new file mode 100644 index 0000000000..3f5dc306a5 --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/three_dots.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/redisinsight/ui/src/assets/img/rdi/upload.svg b/redisinsight/ui/src/assets/img/rdi/upload.svg new file mode 100644 index 0000000000..51e516fe35 --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.tsx b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.tsx index df04e423dc..72028022fb 100644 --- a/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.tsx +++ b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.tsx @@ -79,4 +79,4 @@ const MonacoYaml = (props: Props) => { ) } -export default MonacoYaml +export default React.memo(MonacoYaml) diff --git a/redisinsight/ui/src/components/notifications/success-messages.tsx b/redisinsight/ui/src/components/notifications/success-messages.tsx index 59614b0915..3661d6e33d 100644 --- a/redisinsight/ui/src/components/notifications/success-messages.tsx +++ b/redisinsight/ui/src/components/notifications/success-messages.tsx @@ -275,4 +275,8 @@ export default { title: 'Database already exists', message: 'No new database connections have been added.', }), + SUCCESS_RESET_PIPELINE: () => ({ + title: 'Pipeline has been reset', + message: 'The RDI pipeline has been reset, consider flushing the target Redis database.', + }), } diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index 4283be815b..027d917a64 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -148,6 +148,9 @@ enum ApiEndpoints { RDI_PIPELINE_JOB_FUNCTIONS = 'pipeline/job-functions', RDI_STATISTICS = 'statistics', RDI_PIPELINE_STATUS = 'pipeline/status', + RDI_PIPELINE_STOP = 'pipeline/stop', + RDI_PIPELINE_START = 'pipeline/start', + RDI_PIPELINE_RESET = 'pipeline/reset', } export enum CustomHeaders { diff --git a/redisinsight/ui/src/pages/rdi/instance/InstancePage.spec.tsx b/redisinsight/ui/src/pages/rdi/instance/InstancePage.spec.tsx index 59c0054ad4..919755f6e4 100644 --- a/redisinsight/ui/src/pages/rdi/instance/InstancePage.spec.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/InstancePage.spec.tsx @@ -26,7 +26,7 @@ import { } from 'uiSrc/slices/instances/instances' import { setConnectedInstance } from 'uiSrc/slices/rdi/instances' import { PageNames, Pages } from 'uiSrc/constants' -import { setPipelineInitialState } from 'uiSrc/slices/rdi/pipeline' +import { getPipelineStatus, setPipelineInitialState } from 'uiSrc/slices/rdi/pipeline' import { clearExpertChatHistory } from 'uiSrc/slices/panels/aiAssistant' import InstancePage, { Props } from './InstancePage' @@ -99,6 +99,7 @@ describe('InstancePage', () => { ] const expectedActions = [ + getPipelineStatus(), setAppContextConnectedRdiInstanceId(''), setPipelineInitialState(), resetPipelineManagement(), @@ -125,6 +126,7 @@ describe('InstancePage', () => { }) const expectedActions = [ + getPipelineStatus(), setAppContextConnectedRdiInstanceId(''), setPipelineInitialState(), resetPipelineManagement(), diff --git a/redisinsight/ui/src/pages/rdi/instance/InstancePage.tsx b/redisinsight/ui/src/pages/rdi/instance/InstancePage.tsx index 5a01bee2ce..ed2e6347a3 100644 --- a/redisinsight/ui/src/pages/rdi/instance/InstancePage.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/InstancePage.tsx @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux' import { useHistory, useLocation, useParams } from 'react-router-dom' import { Formik, FormikProps } from 'formik' +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui' import { appContextSelector, resetDatabaseContext, @@ -17,17 +18,24 @@ import { } from 'uiSrc/slices/instances/instances' import { deployPipelineAction, + getPipelineStatusAction, rdiPipelineSelector, + resetPipelineAction, + resetPipelineChecked, setPipelineInitialState, } from 'uiSrc/slices/rdi/pipeline' -import { IPipeline } from 'uiSrc/slices/interfaces' +import { IActionPipelineResultProps, IPipeline } from 'uiSrc/slices/interfaces' import { createAxiosError, Nullable, pipelineToJson } from 'uiSrc/utils' import { rdiErrorMessages } from 'uiSrc/pages/rdi/constants' import { addErrorNotification } from 'uiSrc/slices/app/notifications' +import { RdiInstancePageTemplate } from 'uiSrc/templates' +import { RdiInstanceHeader } from 'uiSrc/components' +import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' import InstancePageRouter from './InstancePageRouter' -import { ConfirmLeavePagePopup } from './components' +import { ConfirmLeavePagePopup, RdiPipelineHeader } from './components' import { useUndeployedChangesPrompt } from './hooks' +import styles from './styles.module.scss' export interface Props { routes: IRoute[] @@ -45,7 +53,7 @@ const RdiInstancePage = ({ routes = [] }: Props) => { const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() const { lastPage, contextRdiInstanceId } = useSelector(appContextSelector) - const { data } = useSelector(rdiPipelineSelector) + const { data, resetChecked } = useSelector(rdiPipelineSelector) const { showModal, handleCloseModal, handleConfirmLeave } = useUndeployedChangesPrompt() const [initialFormValues, setInitialFormValues] = useState(getInitialValues(data)) @@ -85,6 +93,28 @@ const RdiInstancePage = ({ routes = [] }: Props) => { dispatch(setPipelineDialogState(true)) }, []) + const actionPipelineCallback = (event: TelemetryEvent, result: IActionPipelineResultProps) => { + sendEventTelemetry({ + event, + eventData: { + id: rdiInstanceId, + ...result, + } + }) + dispatch(getPipelineStatusAction(rdiInstanceId)) + } + + const updatePipelineStatus = () => { + if (resetChecked) { + dispatch(resetPipelineChecked(false)) + dispatch(resetPipelineAction(rdiInstanceId, + (result: IActionPipelineResultProps) => actionPipelineCallback(TelemetryEvent.RDI_PIPELINE_RESET, result), + (result: IActionPipelineResultProps) => actionPipelineCallback(TelemetryEvent.RDI_PIPELINE_RESET, result))) + } else { + dispatch(getPipelineStatusAction(rdiInstanceId)) + } + } + const onSubmit = (values: IPipeline) => { const JSONValues = pipelineToJson(values, (errors) => { dispatch(addErrorNotification(createAxiosError({ @@ -94,7 +124,8 @@ const RdiInstancePage = ({ routes = [] }: Props) => { if (!JSONValues) { return } - dispatch(deployPipelineAction(rdiInstanceId, JSONValues)) + dispatch(deployPipelineAction(rdiInstanceId, JSONValues, updatePipelineStatus, + () => dispatch(getPipelineStatusAction(rdiInstanceId))),) } return ( @@ -105,8 +136,18 @@ const RdiInstancePage = ({ routes = [] }: Props) => { innerRef={formikRef} > <> - - {showModal && } + + + + + + + + + + {showModal && } + + ) diff --git a/redisinsight/ui/src/pages/rdi/instance/components/confirm-leave-page-popup/ConfirmLeavePagePopup.tsx b/redisinsight/ui/src/pages/rdi/instance/components/confirm-leave-page-popup/ConfirmLeavePagePopup.tsx index 41587d0dc3..92fe0e2f99 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/confirm-leave-page-popup/ConfirmLeavePagePopup.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/confirm-leave-page-popup/ConfirmLeavePagePopup.tsx @@ -7,7 +7,7 @@ import { import { useParams } from 'react-router-dom' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import Download from 'uiSrc/pages/rdi/pipeline-management/components/download/Download' +import Download from 'uiSrc/pages/rdi/instance/components/download/Download' import styles from './styles.module.scss' diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/download/Download.spec.tsx b/redisinsight/ui/src/pages/rdi/instance/components/download/Download.spec.tsx similarity index 86% rename from redisinsight/ui/src/pages/rdi/pipeline-management/components/download/Download.spec.tsx rename to redisinsight/ui/src/pages/rdi/instance/components/download/Download.spec.tsx index 92ab68998c..86bfa0cbed 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/download/Download.spec.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/download/Download.spec.tsx @@ -35,6 +35,17 @@ describe('Download', () => { expect(render()).toBeTruthy() }) + it('should call onClose when download clicked', async () => { + const onClose = jest.fn() + render() + + await act(() => { + fireEvent.click(screen.getByTestId('download-pipeline-btn')) + }) + + expect(onClose).toBeCalledTimes(1) + }) + it('should call proper telemetry event when button is clicked', async () => { const sendEventTelemetryMock = jest.fn(); (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/download/Download.tsx b/redisinsight/ui/src/pages/rdi/instance/components/download/Download.tsx similarity index 84% rename from redisinsight/ui/src/pages/rdi/pipeline-management/components/download/Download.tsx rename to redisinsight/ui/src/pages/rdi/instance/components/download/Download.tsx index 83e33ef711..aaabcf1b61 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/download/Download.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/download/Download.tsx @@ -9,12 +9,16 @@ import { useParams } from 'react-router-dom' import { IPipeline } from 'uiSrc/slices/interfaces' import { rdiPipelineSelector } from 'uiSrc/slices/rdi/pipeline' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import saveIcon from 'uiSrc/assets/img/rdi/save.svg?react' + +import styles from './styles.module.scss' interface Props { dataTestid?: string + onClose?: () => void } -const Download = ({ dataTestid }: Props) => { +const Download = ({ dataTestid, onClose }: Props) => { const { loading } = useSelector(rdiPipelineSelector) const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() @@ -39,13 +43,16 @@ const Download = ({ dataTestid }: Props) => { const content = await zip.generateAsync({ type: 'blob' }) saveAs(content, 'RDI_pipeline.zip') + + onClose?.() } return ( ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +jest.mock('uiSrc/slices/rdi/pipeline', () => ({ + ...jest.requireActual('uiSrc/slices/rdi/pipeline'), + rdiPipelineStatusSelector: jest.fn().mockReturnValue({ + loading: false, + data: {}, + error: '', + }), +})) + +jest.mock('formik') + +const mockHandleSubmit = jest.fn() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('RdiPipelineHeader', () => { + beforeEach(() => { + const mockUseFormikContext = { + handleSubmit: mockHandleSubmit, + values: MOCK_RDI_PIPELINE_DATA, + }; + (useFormikContext as jest.Mock).mockReturnValue(mockUseFormikContext) + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call proper actions', () => { + render() + + const expectedActions = [ + getPipelineStatus(), + ] + + expect(store.getActions().slice(0, expectedActions.length)).toEqual(expectedActions) + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/RdiPipelineHeader.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/RdiPipelineHeader.tsx new file mode 100644 index 0000000000..5987a02230 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/RdiPipelineHeader.tsx @@ -0,0 +1,53 @@ +import { + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui' +import React, { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' + +import { get } from 'lodash' +import { getPipelineStatusAction, rdiPipelineStatusSelector } from 'uiSrc/slices/rdi/pipeline' +import CurrentPipelineStatus from './components/current-pipeline-status' + +import PipelineActions from './components/pipeline-actions' +import styles from './styles.module.scss' + +const RdiPipelineHeader = () => { + const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() + const { data: statusData, error: statusError } = useSelector(rdiPipelineStatusSelector) + const dispatch = useDispatch() + + let intervalId: any + + useEffect(() => { + if (!intervalId) { + dispatch(getPipelineStatusAction(rdiInstanceId)) + intervalId = setInterval(() => { + dispatch(getPipelineStatusAction(rdiInstanceId)) + }, 10000) + } + return () => clearInterval(intervalId) + }, []) + + const pipelineStatus = statusData ? get(statusData, ['pipelines', 'default', 'status']) : undefined + const pipelineState = statusData ? get(statusData, ['pipelines', 'default', 'state']) : undefined + const collectorStatus = statusData ? get(statusData, ['components', 'collector-source', 'status']) : undefined + + return ( + + + + + + + ) +} + +export default RdiPipelineHeader diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.spec.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.spec.tsx new file mode 100644 index 0000000000..7864301149 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.spec.tsx @@ -0,0 +1,111 @@ +import { useFormikContext } from 'formik' +import { cloneDeep } from 'lodash' +import React from 'react' + +import { MOCK_RDI_PIPELINE_DATA } from 'uiSrc/mocks/data/rdi' +import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' +import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' +import DeployPipelineButton, { Props } from './DeployPipelineButton' + +const mockedProps: Props = { + loading: false, + disabled: false, +} + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +jest.mock('uiSrc/slices/rdi/pipeline', () => ({ + ...jest.requireActual('uiSrc/slices/rdi/pipeline'), + rdiPipelineSelector: jest.fn().mockReturnValue({ + loading: false, + }), +})) + +jest.mock('formik') + +const mockHandleSubmit = jest.fn() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('DeployPipelineButton', () => { + beforeEach(() => { + const mockUseFormikContext = { + handleSubmit: mockHandleSubmit, + values: MOCK_RDI_PIPELINE_DATA, + }; + (useFormikContext as jest.Mock).mockReturnValue(mockUseFormikContext) + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + describe('TelemetryEvent', () => { + beforeEach(() => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + + render() + }) + + it('should call proper telemetry on Deploy', () => { + fireEvent.click(screen.getByTestId('deploy-rdi-pipeline')) + fireEvent.click(screen.getByTestId('deploy-confirm-btn')) + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.RDI_DEPLOY_CLICKED, + eventData: + { + id: 'rdiInstanceId', + reset: false, + jobsNumber: 2, + } + }) + }) + + it('should reset true if reset checkbox is in the checked state telemetry on Deploy', () => { + fireEvent.click(screen.getByTestId('deploy-rdi-pipeline')) + + const el = screen.getByTestId('reset-pipeline-checkbox') as HTMLInputElement + expect(el.checked).toBe(false) + fireEvent.click(el) + expect(el.checked).toBe(true) + fireEvent.click(screen.getByTestId('deploy-confirm-btn')) + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.RDI_DEPLOY_CLICKED, + eventData: + { + id: 'rdiInstanceId', + reset: false, + jobsNumber: 2, + } + }) + }) + }) + + it('should open confirmation popover', () => { + render() + + expect(screen.queryByTestId('deploy-confirm-btn')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('deploy-rdi-pipeline')) + + expect(screen.queryByTestId('deploy-confirm-btn')).toBeInTheDocument() + }) + + it('should call onSubmit and close popover', () => { + render() + + fireEvent.click(screen.getByTestId('deploy-rdi-pipeline')) + fireEvent.click(screen.getByTestId('deploy-confirm-btn')) + + expect(mockHandleSubmit).toBeCalled() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.tsx new file mode 100644 index 0000000000..468808b814 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.tsx @@ -0,0 +1,146 @@ +import { + EuiButton, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiOutsideClickDetector, + EuiPopover, + EuiSpacer, + EuiText, + EuiTitle, + EuiToolTip, +} from '@elastic/eui' +import cx from 'classnames' +import { useFormikContext } from 'formik' +import React, { useState } from 'react' +import { useDispatch } from 'react-redux' +import { useParams } from 'react-router-dom' + +import { RdiPipeline } from 'src/modules/rdi/models' +import RocketIcon from 'uiSrc/assets/img/rdi/rocket.svg?react' +import { resetPipelineChecked } from 'uiSrc/slices/rdi/pipeline' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import styles from './styles.module.scss' + +export interface Props { + loading: boolean + disabled: boolean +} + +const DeployPipelineButton = ({ loading, disabled }: Props) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const [resetPipeline, setResetPipeline] = useState(false) + + const { values, handleSubmit } = useFormikContext() + const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() + const dispatch = useDispatch() + + const handleDeployPipeline = () => { + sendEventTelemetry({ + event: TelemetryEvent.RDI_DEPLOY_CLICKED, + eventData: { + id: rdiInstanceId, + reset: resetPipeline, + jobsNumber: values?.jobs?.length + } + }) + setIsPopoverOpen(false) + setResetPipeline(false) + handleSubmit() + } + + const handleClosePopover = () => { + setIsPopoverOpen(false) + } + + const handleClickDeploy = () => { + setIsPopoverOpen(true) + } + + const handleSelectReset = (reset: boolean) => { + setResetPipeline(reset) + dispatch(resetPipelineChecked(reset)) + } + + return ( + + + Deploy Pipeline + + )} + > + + Are you sure you want to deploy the pipeline? + + + When deployed, this local configuration will overwrite any existing pipeline. + + After deployment, consider flushing the target Redis database and resetting the pipeline to ensure that all data is reprocessed. + +
+ handleSelectReset(e.target.checked)} + data-testId="reset-pipeline-checkbox" + /> + + + + +
+ + + + Deploy + + + +
+
+ ) +} + +export default DeployPipelineButton diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/index.ts b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/index.ts new file mode 100644 index 0000000000..5d6c65ca0e --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/index.ts @@ -0,0 +1,3 @@ +import DeployPipelineButton from './DeployPipelineButton' + +export default DeployPipelineButton diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/styles.module.scss b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/styles.module.scss new file mode 100644 index 0000000000..c320919b0e --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/styles.module.scss @@ -0,0 +1,50 @@ +.resetPipelineCheckbox { + font-weight: 400; + font-size: 14px; + + :global(.euiCheckbox__square) { + width: 18px !important; + height: 18px !important; + border: 1px solid var(--controlsBorderColor) !important; + background-color: inherit !important; + border-radius: 4px !important; + box-shadow: none !important; + } + + :global(.euiCheckbox__label) { + color: var(--controlsLabelColor) !important; + } + + :global(.euiCheckbox .euiCheckbox__input:checked + .euiCheckbox__square) { + background-color: var(--euiColorPrimary) !important; + } +} + +.checked { + :global(.euiCheckbox__square) { + background-color: var(--euiColorPrimary) !important; + } +} + + +:global(.euiPanel).popover { + max-width: 450px !important; + word-wrap: break-word; +} + +.wrapper { + padding: 0 16px 16px; +} + +.divider { + // margin: 0 14px; + height: 20px; + width: 1px; +} + +.checkbox { + display: flex; + align-items: center; + gap: 4px; + color: var(--controlsLabelColor) !important; +} diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/index.ts b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/index.ts new file mode 100644 index 0000000000..0bf24f748b --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/index.ts @@ -0,0 +1,11 @@ +import DeployPipelineButton from './deploy-pipeline-button' +import ResetPipelineButton from './reset-pipeline-button' +import StartPipelineButton from './start-pipeline-button' +import StopPipelineButton from './stop-pipeline-button' + +export default { + DeployPipelineButton, + ResetPipelineButton, + StartPipelineButton, + StopPipelineButton, +} diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/ResetPipelineButton.spec.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/ResetPipelineButton.spec.tsx new file mode 100644 index 0000000000..17368303e0 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/ResetPipelineButton.spec.tsx @@ -0,0 +1,48 @@ +import React from 'react' + +import { fireEvent, render, screen, waitFor } from 'uiSrc/utils/test-utils' +import ResetPipelineButton, { PipelineButtonProps } from './ResetPipelineButton' + +const mockedProps: PipelineButtonProps = { + loading: false, + disabled: false, + onClick: jest.fn(), +} + +describe('ResetPipelineButton', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should show reset info text when hovered', async () => { + render() + + fireEvent.mouseOver(screen.getByTestId('reset-pipeline-btn')) + await waitFor(() => screen.getByText(/flushing the target Redis database/)) + expect(screen.getByText(/flushing the target Redis database/)).toBeInTheDocument() + }) + + it('should call onClick when clicked', () => { + const onClick = jest.fn() + render() + + fireEvent.click(screen.getByTestId('reset-pipeline-btn')) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should not be clickable event, when disabled || loading', () => { + const onClick = jest.fn() + render() + + fireEvent.click(screen.getByTestId('reset-pipeline-btn')) + expect(onClick).not.toHaveBeenCalled() + }) + + it('should not be clickable event, when loading', () => { + const onClick = jest.fn() + render() + + fireEvent.click(screen.getByTestId('reset-pipeline-btn')) + expect(onClick).not.toHaveBeenCalled() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/ResetPipelineButton.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/ResetPipelineButton.tsx new file mode 100644 index 0000000000..ec1deadf47 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/ResetPipelineButton.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { EuiButton, EuiSpacer, EuiToolTip } from '@elastic/eui' +import cx from 'classnames' +import ResetIcon from 'uiSrc/assets/img/rdi/reset.svg?react' +import styles from '../styles.module.scss' + +export interface PipelineButtonProps { + onClick: () => void + disabled: boolean + loading: boolean +} + +const ResetPipelineButton = ({ onClick, disabled, loading }: PipelineButtonProps) => ( + +

The pipeline will take a new snapshot of the data and process it, then continue tracking changes.

+ +

+ Before resetting the RDI pipeline, consider stopping the pipeline and flushing the target Redis database. +

+ + ) : null} + anchorClassName={(disabled || loading) ? styles.disabled : styles.tooltip} + > + + Reset Pipeline + +
+) + +export default ResetPipelineButton diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/index.ts b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/index.ts new file mode 100644 index 0000000000..56b8d50a42 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/index.ts @@ -0,0 +1,3 @@ +import ResetPipelineButton from './ResetPipelineButton' + +export default ResetPipelineButton diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/StartPipelineButton.spec.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/StartPipelineButton.spec.tsx new file mode 100644 index 0000000000..c912a9d95a --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/StartPipelineButton.spec.tsx @@ -0,0 +1,49 @@ +import React from 'react' + +import { fireEvent, render, screen, waitFor } from 'uiSrc/utils/test-utils' +import StartPipelineButton from './StartPipelineButton' +import { PipelineButtonProps } from '../reset-pipeline-button/ResetPipelineButton' + +const mockedProps: PipelineButtonProps = { + loading: false, + disabled: false, + onClick: jest.fn(), +} + +describe('StartPipelineButton', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should show reset info text when hovered', async () => { + render() + + fireEvent.mouseOver(screen.getByTestId('start-pipeline-btn')) + await waitFor(() => screen.getByText(/Start the pipeline to resume processing new data arrivals/)) + expect(screen.getByText(/Start the pipeline to resume processing new data arrivals/)).toBeInTheDocument() + }) + + it('should call onClick when clicked', () => { + const onClick = jest.fn() + render() + + fireEvent.click(screen.getByTestId('start-pipeline-btn')) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should not be clickable event, when disabled || loading', () => { + const onClick = jest.fn() + render() + + fireEvent.click(screen.getByTestId('start-pipeline-btn')) + expect(onClick).not.toHaveBeenCalled() + }) + + it('should not be clickable event, when loading', () => { + const onClick = jest.fn() + render() + + fireEvent.click(screen.getByTestId('start-pipeline-btn')) + expect(onClick).not.toHaveBeenCalled() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/StartPipelineButton.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/StartPipelineButton.tsx new file mode 100644 index 0000000000..9e197569ee --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/StartPipelineButton.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { EuiButton, EuiToolTip } from '@elastic/eui' +import cx from 'classnames' + +import StartIcon from 'uiSrc/assets/img/rdi/playFilled.svg?react' + +import { PipelineButtonProps } from '../reset-pipeline-button/ResetPipelineButton' +import styles from '../styles.module.scss' + +const StartPipelineButton = ({ onClick, disabled, loading }: PipelineButtonProps) => ( + + + Start Pipeline + + +) + +export default StartPipelineButton diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/index.ts b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/index.ts new file mode 100644 index 0000000000..62bf90ab9b --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/index.ts @@ -0,0 +1,3 @@ +import ResetPipelineButton from '../reset-pipeline-button' + +export default ResetPipelineButton diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/StopPipelineButton.spec.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/StopPipelineButton.spec.tsx new file mode 100644 index 0000000000..10b2443077 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/StopPipelineButton.spec.tsx @@ -0,0 +1,49 @@ +import React from 'react' + +import { fireEvent, render, screen, waitFor } from 'uiSrc/utils/test-utils' +import StopPipelineButton from './StopPipelineButton' +import { PipelineButtonProps } from '../reset-pipeline-button/ResetPipelineButton' + +const mockedProps: PipelineButtonProps = { + loading: false, + disabled: false, + onClick: jest.fn(), +} + +describe('StopPipelineButton', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should show reset info text when hovered', async () => { + render() + + fireEvent.mouseOver(screen.getByTestId('stop-pipeline-btn')) + await waitFor(() => screen.getByText(/Stop the pipeline to prevent processing of new data arrivals/)) + expect(screen.getByText(/Stop the pipeline to prevent processing of new data arrivals/)).toBeInTheDocument() + }) + + it('should call onClick when clicked', () => { + const onClick = jest.fn() + render() + + fireEvent.click(screen.getByTestId('stop-pipeline-btn')) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should not be clickable event, when disabled || loading', () => { + const onClick = jest.fn() + render() + + fireEvent.click(screen.getByTestId('stop-pipeline-btn')) + expect(onClick).not.toHaveBeenCalled() + }) + + it('should not be clickable event, when loading', () => { + const onClick = jest.fn() + render() + + fireEvent.click(screen.getByTestId('stop-pipeline-btn')) + expect(onClick).not.toHaveBeenCalled() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/StopPipelineButton.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/StopPipelineButton.tsx new file mode 100644 index 0000000000..f4963749cb --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/StopPipelineButton.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { EuiButton, EuiToolTip } from '@elastic/eui' +import cx from 'classnames' + +import StopIcon from 'uiSrc/assets/img/rdi/stopFilled.svg?react' + +import { PipelineButtonProps } from '../reset-pipeline-button/ResetPipelineButton' +import styles from '../styles.module.scss' + +const StopPipelineButton = ({ onClick, disabled, loading }: PipelineButtonProps) => ( + + + Stop Pipeline + + +) + +export default StopPipelineButton diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/index.ts b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/index.ts new file mode 100644 index 0000000000..cc2a4606d1 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/index.ts @@ -0,0 +1,3 @@ +import StopPipelineButton from './StopPipelineButton' + +export default StopPipelineButton diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/styles.module.scss b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/styles.module.scss new file mode 100644 index 0000000000..c7159d9df0 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/styles.module.scss @@ -0,0 +1,20 @@ +.pipelineBtn { + border: 1px solid var(--euiColorSecondary) !important; + + svg { + color: var(--buttonSecondaryTextColor) !important; + } +} + +.btnDisabled { + border: 1px solid var(--controlsBorderColor) !important; + color: var(--controlsBorderColor) !important; + cursor: not-allowed; + svg { + color: var(--controlsBorderColor) !important; + } +} + +.disabled { + cursor: not-allowed; +} diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/CurrentPipelineStatus.spec.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/CurrentPipelineStatus.spec.tsx new file mode 100644 index 0000000000..a3390d902e --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/CurrentPipelineStatus.spec.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +import { fireEvent, render, screen, waitFor } from 'uiSrc/utils/test-utils' +import { PipelineState } from 'uiSrc/slices/interfaces' +import CurrentPipelineStatus, { Props } from './CurrentPipelineStatus' + +const mockedProps: Props = { + pipelineState: PipelineState.CDC, + statusError: '' +} + +describe('CurrentPipelineStatus', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should show status based on pipelineState prop', () => { + render() + expect(screen.getByText('Streaming')).toBeInTheDocument() + }) + + it('should show error label and tooltip when statusError is not empty', async () => { + const errorMessage = 'Some Error Message' + render() + expect(screen.getByText('Error')).toBeInTheDocument() + + fireEvent.mouseOver(screen.getByTestId('pipeline-state-badge')) + await waitFor(() => screen.getByText(errorMessage)) + expect(screen.getByText(errorMessage)).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/CurrentPipelineStatus.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/CurrentPipelineStatus.tsx new file mode 100644 index 0000000000..a8f78ffaaa --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/CurrentPipelineStatus.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { EuiIcon, EuiTitle, EuiToolTip } from '@elastic/eui' +import initialSyncIcon from 'uiSrc/assets/img/rdi/pipelineStatuses/initial_sync.svg?react' +import streamingIcon from 'uiSrc/assets/img/rdi/pipelineStatuses/streaming.svg?react' +import notRunningIcon from 'uiSrc/assets/img/rdi/pipelineStatuses/not_running.svg?react' +import statusErrorIcon from 'uiSrc/assets/img/rdi/pipelineStatuses/status_error.svg?react' +import { PipelineState } from 'uiSrc/slices/interfaces' +import { Maybe, formatLongName } from 'uiSrc/utils' +import styles from './styles.module.scss' + +export interface Props { + pipelineState?: PipelineState + statusError?: string +} + +const CurrentPipelineStatus = ({ pipelineState, statusError }: Props) => { + const getPipelineStateIconAndLabel = (pipelineState: Maybe) => { + switch (pipelineState) { + case PipelineState.InitialSync: + return { icon: initialSyncIcon, label: 'Initial sync' } + case PipelineState.CDC: + return { icon: streamingIcon, label: 'Streaming' } + case PipelineState.NotRunning: + return { icon: notRunningIcon, label: 'Not running' } + default: + return { icon: statusErrorIcon, label: 'Error' } + } + } + const stateInfo = getPipelineStateIconAndLabel(pipelineState) + const errorTooltipContent = statusError && formatLongName(statusError) + + return ( +
+ +
Pipeline State:
+
+ +
+ + {stateInfo.label} +
+
+
+ ) +} + +export default CurrentPipelineStatus diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/index.ts b/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/index.ts new file mode 100644 index 0000000000..c54a1d4149 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/index.ts @@ -0,0 +1,3 @@ +import CurrentPipelineStatus from './CurrentPipelineStatus' + +export default CurrentPipelineStatus diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/styles.module.scss b/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/styles.module.scss new file mode 100644 index 0000000000..d762c0e75d --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/styles.module.scss @@ -0,0 +1,27 @@ +.stateBadge { + background-color: var(--euiColorEmptyShade); + padding: 2px 4px; + margin-left: 4px; + display: flex; + align-items: center; + gap: 4px; + border-radius: 4px; + + font-size: 12px; + font-weight: 500; + line-height: 14.4px; + + svg { + width: 20px; + height: 20px; + } +} + +.stateWrapper { + display: flex; + align-items: center; +} + +.tooltip { + cursor: pointer; +} diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/fetch-pipeline-popover/FetchPipelinePopover.spec.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/fetch-pipeline-popover/FetchPipelinePopover.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/rdi/pipeline-management/components/fetch-pipeline-popover/FetchPipelinePopover.spec.tsx rename to redisinsight/ui/src/pages/rdi/instance/components/header/components/fetch-pipeline-popover/FetchPipelinePopover.spec.tsx diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/fetch-pipeline-popover/FetchPipelinePopover.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/fetch-pipeline-popover/FetchPipelinePopover.tsx similarity index 83% rename from redisinsight/ui/src/pages/rdi/pipeline-management/components/fetch-pipeline-popover/FetchPipelinePopover.tsx rename to redisinsight/ui/src/pages/rdi/instance/components/header/components/fetch-pipeline-popover/FetchPipelinePopover.tsx index b7db3b657f..4945266aac 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/fetch-pipeline-popover/FetchPipelinePopover.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/fetch-pipeline-popover/FetchPipelinePopover.tsx @@ -12,10 +12,16 @@ import ConfirmationPopover from 'uiSrc/pages/rdi/components/confirmation-popover import { IPipeline } from 'uiSrc/slices/interfaces' import { fetchRdiPipeline, rdiPipelineSelector } from 'uiSrc/slices/rdi/pipeline' import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' -import Download from 'uiSrc/pages/rdi/pipeline-management/components/download/Download' -import UploadIcon from 'uiSrc/assets/img/rdi/upload_from_server.svg?react' +import Download from 'uiSrc/pages/rdi/instance/components/download/Download' +import downloadIcon from 'uiSrc/assets/img/rdi/download.svg?react' -const FetchPipelinePopover = () => { +import styles from './styles.module.scss' + +export interface Props { + onClose?: () => void +} + +const FetchPipelinePopover = ({ onClose }: Props) => { const { loading, data } = useSelector(rdiPipelineSelector) const { resetForm } = useFormikContext() @@ -28,6 +34,7 @@ const FetchPipelinePopover = () => { dispatch( fetchRdiPipeline(rdiInstanceId, () => { resetForm() + onClose?.() }) ) } @@ -67,8 +74,9 @@ const FetchPipelinePopover = () => { button={( ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +jest.mock('uiSrc/slices/rdi/pipeline', () => ({ + ...jest.requireActual('uiSrc/slices/rdi/pipeline'), + rdiPipelineSelector: jest.fn().mockReturnValue({ + loading: false, + }), +})) + +jest.mock('formik') + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('PipelineActions', () => { + beforeEach(() => { + const mockUseFormikContext = { + handleSubmit: jest.fn(), + values: MOCK_RDI_PIPELINE_DATA, + }; + (useFormikContext as jest.Mock).mockReturnValue(mockUseFormikContext) + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should display stopBtn if collectorStatus is ready', () => { + render() + expect(screen.getByText('Stop Pipeline')).toBeInTheDocument() + }) + + it('should display startBtn if collectorStatus is not ready', () => { + render() + expect(screen.getByText('Start Pipeline')).toBeInTheDocument() + }) + + it('should display startBtn if collectorStatus is not ready', () => { + render() + expect(screen.getByText('Start Pipeline')).toBeInTheDocument() + }) + + describe('TelemetryEvent', () => { + beforeEach(() => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + }) + + it('should call proper telemetry on reset btn click', () => { + render() + fireEvent.click(screen.getByTestId('reset-pipeline-btn')) + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.RDI_PIPELINE_RESET_CLICKED, + eventData: + { + id: 'rdiInstanceId', + pipelineStatus: mockedProps.pipelineStatus + } + }) + }) + + it('should call proper telemetry on start btn click', () => { + render() + fireEvent.click(screen.getByTestId('start-pipeline-btn')) + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.RDI_PIPELINE_START_CLICKED, + eventData: + { + id: 'rdiInstanceId', + } + }) + }) + + it('should call proper telemetry on stop btn click', () => { + render() + fireEvent.click(screen.getByTestId('stop-pipeline-btn')) + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.RDI_PIPELINE_STOP_CLICKED, + eventData: + { + id: 'rdiInstanceId', + } + }) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/PipelineActions.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/PipelineActions.tsx new file mode 100644 index 0000000000..b953139ea6 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/PipelineActions.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import { + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' + +import { + getPipelineStatusAction, + rdiPipelineActionSelector, + rdiPipelineSelector, + resetPipelineAction, + startPipelineAction, + stopPipelineAction +} from 'uiSrc/slices/rdi/pipeline' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import { CollectorStatus, IActionPipelineResultProps, PipelineAction, PipelineStatus } from 'uiSrc/slices/interfaces' +import DeployPipelineButton from '../buttons/deploy-pipeline-button' +import ResetPipelineButton from '../buttons/reset-pipeline-button' +import RdiConfigFileActionMenu from '../rdi-config-file-action-menu' +import StopPipelineButton from '../buttons/stop-pipeline-button' +import StartPipelineButton from '../buttons/start-pipeline-button/StartPipelineButton' + +export interface Props { + collectorStatus?: CollectorStatus + pipelineStatus?: PipelineStatus +} + +const PipelineActions = ({ collectorStatus, pipelineStatus }: Props) => { + const { loading: deployLoading } = useSelector(rdiPipelineSelector) + const { loading: actionLoading, action } = useSelector(rdiPipelineActionSelector) + + const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() + + const dispatch = useDispatch() + + const actionPipelineCallback = (event: TelemetryEvent, result: IActionPipelineResultProps) => { + sendEventTelemetry({ + event, + eventData: { + id: rdiInstanceId, + ...result, + } + }) + dispatch(getPipelineStatusAction(rdiInstanceId)) + } + + const onReset = () => { + sendEventTelemetry({ + event: TelemetryEvent.RDI_PIPELINE_RESET_CLICKED, + eventData: { + id: rdiInstanceId, + pipelineStatus, + } + }) + dispatch(resetPipelineAction( + rdiInstanceId, + (result: IActionPipelineResultProps) => + actionPipelineCallback(TelemetryEvent.RDI_PIPELINE_RESET, result), + (result: IActionPipelineResultProps) => + actionPipelineCallback(TelemetryEvent.RDI_PIPELINE_RESET, result) + )) + } + + const onStartPipeline = () => { + sendEventTelemetry({ + event: TelemetryEvent.RDI_PIPELINE_START_CLICKED, + eventData: { + id: rdiInstanceId, + } + }) + dispatch(startPipelineAction( + rdiInstanceId, + (result: IActionPipelineResultProps) => + actionPipelineCallback(TelemetryEvent.RDI_PIPELINE_STARTED, result), + (result: IActionPipelineResultProps) => + actionPipelineCallback(TelemetryEvent.RDI_PIPELINE_STARTED, result), + )) + } + + const onStopPipeline = () => { + sendEventTelemetry({ + event: TelemetryEvent.RDI_PIPELINE_STOP_CLICKED, + eventData: { + id: rdiInstanceId, + } + }) + dispatch(stopPipelineAction( + rdiInstanceId, + (result) => actionPipelineCallback(TelemetryEvent.RDI_PIPELINE_STOPPED, result), + (result) => actionPipelineCallback(TelemetryEvent.RDI_PIPELINE_STOPPED, result) + )) + } + + const isLoadingBtn = (actionBtn: PipelineAction) => action === actionBtn && actionLoading + const disabled = deployLoading || actionLoading + + return ( + + + + + + {collectorStatus === CollectorStatus.Ready ? ( + + ) : ( + + )} + + + + + + + + + ) +} + +export default PipelineActions diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/index.ts b/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/index.ts new file mode 100644 index 0000000000..6a94af2831 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/index.ts @@ -0,0 +1,3 @@ +import PipelineActions from './PipelineActions' + +export default PipelineActions diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/RdiConfigFileActionMenu.spec.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/RdiConfigFileActionMenu.spec.tsx new file mode 100644 index 0000000000..de7856c45d --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/RdiConfigFileActionMenu.spec.tsx @@ -0,0 +1,33 @@ +import { useFormikContext } from 'formik' +import React from 'react' + +import { fireEvent, render, screen, waitFor } from 'uiSrc/utils/test-utils' +import RdiConfigFileActionMenu from './RdiConfigFileActionMenu' + +jest.mock('formik') + +describe('RdiConfigFileActionMenu', () => { + beforeEach(() => { + const mockUseFormikContext = { + handleSubmit: jest.fn(), + resetForm: jest.fn(), + // values: MOCK_RDI_PIPELINE_DATA, + }; + (useFormikContext as jest.Mock).mockReturnValue(mockUseFormikContext) + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should show menu with file actions when clicked', async () => { + render() + const actionBtn = screen.getByTestId('rdi-config-file-action-menu-trigger') as HTMLElement + expect(actionBtn).toBeInTheDocument() + fireEvent.click(screen.getByTestId('rdi-config-file-action-menu-trigger')) + await waitFor(() => screen.getByTestId('upload-file-btn')) + expect(screen.getByTestId('upload-file-btn')).toBeInTheDocument() + expect(screen.getByTestId('upload-pipeline-btn')).toBeInTheDocument() + expect(screen.getByTestId('download-pipeline-btn')).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/RdiConfigFileActionMenu.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/RdiConfigFileActionMenu.tsx new file mode 100644 index 0000000000..29fda5c630 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/RdiConfigFileActionMenu.tsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react' +import { + EuiPopover, + EuiButtonIcon, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui' +import cx from 'classnames' +import threeDots from 'uiSrc/assets/img/rdi/three_dots.svg?react' +import uploadIcon from 'uiSrc/assets/img/rdi/upload.svg?react' +import UploadModal from 'uiSrc/pages/rdi/pipeline-management/components/upload-modal/UploadModal' +import Download from 'uiSrc/pages/rdi/instance/components/download' +import FetchPipelinePopover from '../fetch-pipeline-popover' + +import styles from './styles.module.scss' + +const RdiConfigFileActionMenu = () => { + const [isPopoverOpen, setPopover] = useState(false) + + const onButtonClick = () => { + setPopover(!isPopoverOpen) + } + + const closePopover = () => { + setPopover(false) + } + + const button = ( + + ) + + return ( + + + + + + + + + Upload from file + + + + + + + + + ) +} + +export default RdiConfigFileActionMenu diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/index.ts b/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/index.ts new file mode 100644 index 0000000000..1c44c9c740 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/index.ts @@ -0,0 +1,3 @@ +import RdiConfigFileActionMenu from './RdiConfigFileActionMenu' + +export default RdiConfigFileActionMenu diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/styles.module.scss b/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/styles.module.scss new file mode 100644 index 0000000000..10ec412313 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/styles.module.scss @@ -0,0 +1,16 @@ +.threeDotsBtn { + svg { + color: var(--buttonSecondaryTextColor) !important; + } +} + +.popoverWrapper { + min-width: 200px; +} + +.uploadBtn { + :global(.euiButtonEmpty__text) { + margin-inline: 4px; + } +} + diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/header/index.ts b/redisinsight/ui/src/pages/rdi/instance/components/header/index.ts similarity index 100% rename from redisinsight/ui/src/pages/rdi/pipeline-management/components/header/index.ts rename to redisinsight/ui/src/pages/rdi/instance/components/header/index.ts diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/header/styles.module.scss b/redisinsight/ui/src/pages/rdi/instance/components/header/styles.module.scss similarity index 53% rename from redisinsight/ui/src/pages/rdi/pipeline-management/components/header/styles.module.scss rename to redisinsight/ui/src/pages/rdi/instance/components/header/styles.module.scss index 1d9aa7ad1e..c750620c24 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/header/styles.module.scss +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/styles.module.scss @@ -6,10 +6,3 @@ .wrapper { padding: 0 16px 16px; } - -:global(.euiButtonEmpty--text) { - &:focus, - &:hover { - background-color: transparent !important; - } -} diff --git a/redisinsight/ui/src/pages/rdi/instance/components/index.ts b/redisinsight/ui/src/pages/rdi/instance/components/index.ts index 01a9f78134..e312f1620e 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/index.ts +++ b/redisinsight/ui/src/pages/rdi/instance/components/index.ts @@ -1,5 +1,9 @@ import ConfirmLeavePagePopup from './confirm-leave-page-popup' +import Download from './download/Download' +import RdiPipelineHeader from './header' export { - ConfirmLeavePagePopup + ConfirmLeavePagePopup, + Download, + RdiPipelineHeader, } diff --git a/redisinsight/ui/src/pages/rdi/instance/styles.module.scss b/redisinsight/ui/src/pages/rdi/instance/styles.module.scss new file mode 100644 index 0000000000..83a1251260 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance/styles.module.scss @@ -0,0 +1,4 @@ +.page { + height: 100%; + padding-bottom: 16px; +} diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/PipelineManagementPage.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/PipelineManagementPage.tsx index bb479f16b6..f59c19291b 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/PipelineManagementPage.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/PipelineManagementPage.tsx @@ -12,11 +12,12 @@ import { } from 'uiSrc/slices/app/context' import { formatLongName, setTitle } from 'uiSrc/utils' import SourcePipelineDialog from 'uiSrc/pages/rdi/pipeline-management/components/source-pipeline-dialog' -import { RdiPipelineManagementTemplate } from 'uiSrc/templates' +import Navigation from 'uiSrc/pages/rdi/pipeline-management/components/navigation' import { removeInfiniteNotification } from 'uiSrc/slices/app/notifications' import { InfiniteMessagesIds } from 'uiSrc/components/notifications/components' import PipelinePageRouter from './PipelineManagementPageRouter' +import styles from './styles.module.scss' export interface Props { routes: IRoute[] @@ -67,10 +68,11 @@ const PipelineManagementPage = ({ routes = [] }: Props) => { }, [pathname, lastViewedPage]) return ( - +
+ - +
) } diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/header/RdiPipelineHeader.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/header/RdiPipelineHeader.spec.tsx deleted file mode 100644 index 03225b6770..0000000000 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/header/RdiPipelineHeader.spec.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useFormikContext } from 'formik' -import { cloneDeep } from 'lodash' -import React from 'react' - -import { MOCK_RDI_PIPELINE_DATA } from 'uiSrc/mocks/data/rdi' -import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' -import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' -import RdiPipelineHeader from './RdiPipelineHeader' - -jest.mock('uiSrc/telemetry', () => ({ - ...jest.requireActual('uiSrc/telemetry'), - sendEventTelemetry: jest.fn(), -})) - -jest.mock('uiSrc/slices/rdi/pipeline', () => ({ - ...jest.requireActual('uiSrc/slices/rdi/pipeline'), - rdiPipelineSelector: jest.fn().mockReturnValue({ - loading: false, - }), -})) - -jest.mock('formik') - -const mockHandleSubmit = jest.fn() - -let store: typeof mockedStore -beforeEach(() => { - cleanup() - store = cloneDeep(mockedStore) - store.clearActions() -}) - -describe('RdiPipelineHeader', () => { - beforeEach(() => { - const mockUseFormikContext = { - handleSubmit: mockHandleSubmit, - values: MOCK_RDI_PIPELINE_DATA, - }; - (useFormikContext as jest.Mock).mockReturnValue(mockUseFormikContext) - }) - - it('should render', () => { - expect(render()).toBeTruthy() - }) - - it('should call proper telemetry on Deploy', () => { - const sendEventTelemetryMock = jest.fn(); - (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) - - render() - - fireEvent.click(screen.getByTestId('deploy-rdi-pipeline')) - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.RDI_DEPLOY_CLICKED, - eventData: - { - id: 'rdiInstanceId', - jobsNumber: 2, - } - }) - }) - - it('should open confirmation popover', () => { - render() - - expect(screen.queryByTestId('deploy-confirm-btn')).not.toBeInTheDocument() - - fireEvent.click(screen.getByTestId('deploy-rdi-pipeline')) - - expect(screen.queryByTestId('deploy-confirm-btn')).toBeInTheDocument() - }) - - it('should call onSubmit and close popover', () => { - render() - - fireEvent.click(screen.getByTestId('deploy-rdi-pipeline')) - fireEvent.click(screen.getByTestId('deploy-confirm-btn')) - - expect(mockHandleSubmit).toBeCalled() - }) -}) diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/header/RdiPipelineHeader.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/header/RdiPipelineHeader.tsx deleted file mode 100644 index 2f8d949b57..0000000000 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/header/RdiPipelineHeader.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiOutsideClickDetector, - EuiPopover, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui' -import cx from 'classnames' -import { useFormikContext } from 'formik' -import React, { useState } from 'react' -import { useSelector } from 'react-redux' -import { useParams } from 'react-router-dom' - -import { RdiPipeline } from 'src/modules/rdi/models' -import RocketIcon from 'uiSrc/assets/img/rdi/rocket.svg' -import { rdiPipelineSelector } from 'uiSrc/slices/rdi/pipeline' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import FetchPipelinePopover from 'uiSrc/pages/rdi/pipeline-management/components/fetch-pipeline-popover' -import UploadModal from 'uiSrc/pages/rdi/pipeline-management/components/upload-modal/UploadModal' -import Download from 'uiSrc/pages/rdi/pipeline-management/components/download/Download' - -import styles from './styles.module.scss' - -const RdiPipelineHeader = () => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false) - - const { loading } = useSelector(rdiPipelineSelector) - - const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() - - const { values, handleSubmit } = useFormikContext() - - const handleDeployPipeline = () => { - setIsPopoverOpen(false) - handleSubmit() - } - - const handleClosePopover = () => { - setIsPopoverOpen(false) - } - - const handleClickDeploy = () => { - sendEventTelemetry({ - event: TelemetryEvent.RDI_DEPLOY_CLICKED, - eventData: { - id: rdiInstanceId, - jobsNumber: values?.jobs?.length - } - }) - setIsPopoverOpen(true) - } - - return ( - - - - Pipeline Management - - - -
- - - - Upload from file - - - -
-
- - - - Deploy Pipeline - - )} - > - - Are you sure you want to deploy the pipeline? - - - When deployed, this local configuration will overwrite any existing pipeline. - - - - - Deploy - - - - - - -
- ) -} - -export default RdiPipelineHeader diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/pages/config/Config.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/pages/config/Config.tsx index ea6bfcb5ba..6daca1a1b8 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/pages/config/Config.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/pages/config/Config.tsx @@ -93,10 +93,10 @@ const Config = () => { dispatch(setChangedFile({ name: 'config', status: FileChangeType.Modified })) }, 2000), [data]) - const handleChange = (value: string) => { + const handleChange = useCallback((value: string) => { setFieldValue('config', value) checkIsFileUpdated(value) - } + }, [data]) const handleClosePanel = () => { testConnectionsController?.abort() @@ -171,4 +171,4 @@ const Config = () => { ) } -export default Config +export default React.memo(Config) diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/styles.module.scss b/redisinsight/ui/src/pages/rdi/pipeline-management/styles.module.scss new file mode 100644 index 0000000000..aefcdca0d1 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/styles.module.scss @@ -0,0 +1,76 @@ +.content { + height: calc(100% - 60px); +} + +.wrapper { + display: flex; + width: 100%; + height: 100%; + padding: 0 16px; + overflow: hidden; + + :global(.content) { + display: flex; + flex-direction: column; + border-radius: 8px; + padding: 32px 24px 16px 24px; + margin-left: 16px; + background-color: var(--euiColorEmptyShade); + width: calc(100% - 269px); + } + + :global(.content.isSidePanelOpen) { + max-width: calc(100% - 793px); + } + + :global { + .monaco-editor, .monaco-editor .margin, .monaco-editor .minimap-decorations-layer, .monaco-editor-background { + background-color: var(--browserViewTypePassive) !important; + } + + .rdi { + &__content-header { + display: flex; + justify-content: space-between; + } + + &__title { + font: normal normal normal 16px/19px Graphik, sans-serif; + word-break: break-word; + } + + &__title, + &__text { + margin-bottom: 8px; + } + + &__actions { + display: flex; + justify-content: end; + padding-top: 16px; + } + + &__editorWrapper { + flex: 1 1 auto; + border: 1px solid var(--separatorColorLight) !important; + + :global(.inlineMonacoEditor) { + height: 100%; + } + } + + &__loading { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: var(--browserViewTypePassive) !important; + } + } + } +} + +.page { + height: 100%; + padding-bottom: 16px; +} diff --git a/redisinsight/ui/src/pages/rdi/statistics/StatisticsPage.tsx b/redisinsight/ui/src/pages/rdi/statistics/StatisticsPage.tsx index b459eb5650..9e43eeb027 100644 --- a/redisinsight/ui/src/pages/rdi/statistics/StatisticsPage.tsx +++ b/redisinsight/ui/src/pages/rdi/statistics/StatisticsPage.tsx @@ -8,7 +8,6 @@ import { connectedInstanceSelector } from 'uiSrc/slices/rdi/instances' import { getPipelineStatusAction, rdiPipelineStatusSelector } from 'uiSrc/slices/rdi/pipeline' import { fetchRdiStatistics, rdiStatisticsSelector } from 'uiSrc/slices/rdi/statistics' import { TelemetryEvent, TelemetryPageView, sendEventTelemetry, sendPageViewTelemetry } from 'uiSrc/telemetry' -import RdiInstancePageTemplate from 'uiSrc/templates/rdi-instance-page-template' import { formatLongName, Nullable, setTitle } from 'uiSrc/utils' import { setLastPageContext } from 'uiSrc/slices/app/context' import { PageNames } from 'uiSrc/constants' @@ -99,54 +98,52 @@ const StatisticsPage = () => { const { data: statisticsData } = statisticsResults return ( - -
-
- {pageLoading && ( -
- -
- )} - {!isPipelineDeployed(statusData) ? ( - // TODO add loader - - ) : ( - <> - - onRefresh('processing_performance')} - onRefreshClicked={() => onRefreshClicked('processing_performance')} - onChangeAutoRefresh={(enableAutoRefresh: boolean, refreshRate: string) => - onChangeAutoRefresh('processing_performance', enableAutoRefresh, refreshRate)} - /> - - { - dispatch(fetchRdiStatistics(rdiInstanceId, 'data_streams')) - }} - onRefreshClicked={() => onRefreshClicked('data_streams')} - onChangeAutoRefresh={(enableAutoRefresh: boolean, refreshRate: string) => - onChangeAutoRefresh('data_streams', enableAutoRefresh, refreshRate)} - /> - { - dispatch(fetchRdiStatistics(rdiInstanceId, 'clients')) - }} - onRefreshClicked={() => onRefreshClicked('clients')} - onChangeAutoRefresh={(enableAutoRefresh: boolean, refreshRate: string) => - onChangeAutoRefresh('clients', enableAutoRefresh, refreshRate)} - /> - - )} -
+
+
+ {pageLoading && ( +
+ +
+ )} + {!isPipelineDeployed(statusData) ? ( + // TODO add loader + + ) : ( + <> + + onRefresh('processing_performance')} + onRefreshClicked={() => onRefreshClicked('processing_performance')} + onChangeAutoRefresh={(enableAutoRefresh: boolean, refreshRate: string) => + onChangeAutoRefresh('processing_performance', enableAutoRefresh, refreshRate)} + /> + + { + dispatch(fetchRdiStatistics(rdiInstanceId, 'data_streams')) + }} + onRefreshClicked={() => onRefreshClicked('data_streams')} + onChangeAutoRefresh={(enableAutoRefresh: boolean, refreshRate: string) => + onChangeAutoRefresh('data_streams', enableAutoRefresh, refreshRate)} + /> + { + dispatch(fetchRdiStatistics(rdiInstanceId, 'clients')) + }} + onRefreshClicked={() => onRefreshClicked('clients')} + onChangeAutoRefresh={(enableAutoRefresh: boolean, refreshRate: string) => + onChangeAutoRefresh('clients', enableAutoRefresh, refreshRate)} + /> + + )}
- +
) } diff --git a/redisinsight/ui/src/slices/interfaces/rdi.ts b/redisinsight/ui/src/slices/interfaces/rdi.ts index 4a02f6b481..925d48e29f 100644 --- a/redisinsight/ui/src/slices/interfaces/rdi.ts +++ b/redisinsight/ui/src/slices/interfaces/rdi.ts @@ -145,21 +145,40 @@ export enum PipelineStatus { Stopped = 'stopped', } +export enum PipelineState { + InitialSync = 'initial-sync', + CDC = 'cdc', + NotRunning = 'not-running', +} + +export enum CollectorStatus { + Ready = 'ready', + Stopped = 'stopped', + NotReady = 'not-ready', +} + export interface IPipelineStatus { components: Record pipelines: { default?: { status: PipelineStatus - state: unknown - tasks: unknown + state: PipelineState, + tasks: unknown, } } } +export enum PipelineAction { + Start = 'start', + Stop = 'stop', + Reset = 'reset', +} + export interface IStateRdiPipeline { loading: boolean error: string data: Nullable + resetChecked: boolean schema: Nullable strategies: IRdiPipelineStrategies changes: Record @@ -169,6 +188,11 @@ export interface IStateRdiPipeline { error: string data: Nullable } + pipelineAction: { + loading: boolean + error: string + action: Nullable + } } export interface IStateRdiDryRunJob { @@ -248,3 +272,8 @@ export interface IYamlFormatError { filename: string msg: string } + +export interface IActionPipelineResultProps { + success: boolean + error: Nullable +} diff --git a/redisinsight/ui/src/slices/rdi/pipeline.ts b/redisinsight/ui/src/slices/rdi/pipeline.ts index 7a1a700939..c27d5a5e13 100644 --- a/redisinsight/ui/src/slices/rdi/pipeline.ts +++ b/redisinsight/ui/src/slices/rdi/pipeline.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AxiosError } from 'axios' import { apiService, } from 'uiSrc/services' -import { addErrorNotification, addInfiniteNotification } from 'uiSrc/slices/app/notifications' +import { addErrorNotification, addInfiniteNotification, addMessageNotification } from 'uiSrc/slices/app/notifications' import { IStateRdiPipeline, IPipeline, @@ -10,6 +10,8 @@ import { IRdiPipelineStrategy, TJMESPathFunctions, IPipelineStatus, + IActionPipelineResultProps, + PipelineAction, } from 'uiSrc/slices/interfaces/rdi' import { getApiErrorMessage, @@ -23,12 +25,14 @@ import { import { EnhancedAxiosError } from 'uiSrc/slices/interfaces' import { INFINITE_MESSAGES } from 'uiSrc/components/notifications/components' import { ApiEndpoints } from 'uiSrc/constants' +import successMessages from 'uiSrc/components/notifications/success-messages' import { AppDispatch, RootState } from '../store' export const initialState: IStateRdiPipeline = { loading: false, error: '', data: null, + resetChecked: false, schema: null, strategies: { loading: false, @@ -42,6 +46,11 @@ export const initialState: IStateRdiPipeline = { data: null, error: '', }, + pipelineAction: { + loading: false, + action: null, + error: '', + } } const rdiPipelineSlice = createSlice({ @@ -49,6 +58,9 @@ const rdiPipelineSlice = createSlice({ initialState, reducers: { setPipelineInitialState: () => initialState, + resetPipelineChecked: (state, { payload }: PayloadAction) => { + state.resetChecked = payload + }, setPipeline: (state, { payload }: PayloadAction) => { state.data = payload }, @@ -72,6 +84,19 @@ const rdiPipelineSlice = createSlice({ deployPipelineFailure: (state) => { state.loading = false }, + triggerPipelineAction: (state, { payload }: PayloadAction) => { + state.pipelineAction.loading = true + state.pipelineAction.action = payload + state.pipelineAction.error = '' + }, + triggerPipelineActionSuccess: (state) => { + state.pipelineAction.loading = false + state.pipelineAction.action = null + }, + triggerPipelineActionFailure: (state, { payload }: PayloadAction) => { + state.pipelineAction.loading = false + state.pipelineAction.error = payload + }, setPipelineSchema: (state, { payload }: PayloadAction>) => { state.schema = payload }, @@ -125,10 +150,12 @@ const rdiPipelineSlice = createSlice({ }) export const rdiPipelineSelector = (state: RootState) => state.rdi.pipeline +export const rdiPipelineActionSelector = (state: RootState) => state.rdi.pipeline.pipelineAction export const rdiPipelineStrategiesSelector = (state: RootState) => state.rdi.pipeline.strategies export const rdiPipelineStatusSelector = (state: RootState) => state.rdi.pipeline.status export const { + resetPipelineChecked, getPipeline, getPipelineSuccess, getPipelineFailure, @@ -148,6 +175,9 @@ export const { getPipelineStatus, getPipelineStatusSuccess, getPipelineStatusFailure, + triggerPipelineAction, + triggerPipelineActionSuccess, + triggerPipelineActionFailure, } = rdiPipelineSlice.actions // The reducer @@ -366,3 +396,88 @@ export function getPipelineStatusAction( } } } + +export function stopPipelineAction( + rdiInstanceId: string, + onSuccessAction?: (result: IActionPipelineResultProps) => void, + onErrorAction?: (result: IActionPipelineResultProps) => void, +) { + return async (dispatch: AppDispatch) => { + try { + dispatch(triggerPipelineAction(PipelineAction.Stop)) + const { status } = await apiService.post( + getRdiUrl(rdiInstanceId, ApiEndpoints.RDI_PIPELINE_STOP), + ) + + if (isStatusSuccessful(status)) { + dispatch(triggerPipelineActionSuccess()) + onSuccessAction?.({ success: true, error: null }) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + const parsedError = getAxiosError(error as EnhancedAxiosError) + + dispatch(addErrorNotification(parsedError)) + dispatch(triggerPipelineActionFailure(errorMessage)) + onErrorAction?.({ success: false, error: errorMessage }) + } + } +} + +export function startPipelineAction( + rdiInstanceId: string, + onSuccessAction?: (result: IActionPipelineResultProps) => void, + onErrorAction?: (result: IActionPipelineResultProps) => void, +) { + return async (dispatch: AppDispatch) => { + try { + dispatch(triggerPipelineAction(PipelineAction.Start)) + const { status } = await apiService.post( + getRdiUrl(rdiInstanceId, ApiEndpoints.RDI_PIPELINE_START), + ) + + if (isStatusSuccessful(status)) { + dispatch(triggerPipelineActionSuccess()) + onSuccessAction?.({ success: true, error: null }) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + const parsedError = getAxiosError(error as EnhancedAxiosError) + + dispatch(addErrorNotification(parsedError)) + dispatch(triggerPipelineActionFailure(errorMessage)) + onErrorAction?.({ success: false, error: errorMessage }) + } + } +} + +export function resetPipelineAction( + rdiInstanceId: string, + onSuccessAction?: (result: IActionPipelineResultProps) => void, + onErrorAction?: (result: IActionPipelineResultProps) => void, +) { + return async (dispatch: AppDispatch) => { + try { + dispatch(triggerPipelineAction(PipelineAction.Reset)) + const { status } = await apiService.post( + getRdiUrl(rdiInstanceId, ApiEndpoints.RDI_PIPELINE_RESET), + ) + + if (isStatusSuccessful(status)) { + dispatch(triggerPipelineActionSuccess()) + dispatch(addMessageNotification(successMessages.SUCCESS_RESET_PIPELINE())) + onSuccessAction?.({ success: true, error: null }) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + const parsedError = getAxiosError(error as EnhancedAxiosError) + + dispatch(addErrorNotification(parsedError)) + dispatch(triggerPipelineActionFailure(errorMessage)) + onErrorAction?.({ success: false, error: errorMessage }) + } + } +} diff --git a/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts b/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts index 7945fda137..e716370b43 100644 --- a/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts +++ b/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts @@ -33,12 +33,20 @@ import reducer, { getPipelineStatusAction, rdiPipelineSelector, rdiPipelineStatusSelector, + resetPipelineAction, + stopPipelineAction, + startPipelineAction, + triggerPipelineAction, + triggerPipelineActionSuccess, + triggerPipelineActionFailure, + rdiPipelineActionSelector, } from 'uiSrc/slices/rdi/pipeline' import { apiService } from 'uiSrc/services' -import { addErrorNotification, addInfiniteNotification } from 'uiSrc/slices/app/notifications' +import { addErrorNotification, addInfiniteNotification, addMessageNotification } from 'uiSrc/slices/app/notifications' import { INFINITE_MESSAGES } from 'uiSrc/components/notifications/components' -import { FileChangeType } from 'uiSrc/slices/interfaces' +import { FileChangeType, PipelineAction } from 'uiSrc/slices/interfaces' import { parseJMESPathFunctions } from 'uiSrc/utils' +import successMessages from 'uiSrc/components/notifications/success-messages' let store: typeof mockedStore @@ -459,6 +467,74 @@ describe('rdi pipe slice', () => { }) }) + describe('triggerPipelineAction', () => { + it('should set loading = true', () => { + // Arrange + const state = { + ...initialState.pipelineAction, + loading: true, + action: PipelineAction.Start, + error: '', + } + + // Act + const nextState = reducer(initialState, triggerPipelineAction(PipelineAction.Start)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + rdi: { + pipeline: nextState, + } + }) + expect(rdiPipelineActionSelector(rootState)).toEqual(state) + }) + }) + + describe('triggerPipelineActionSuccess', () => { + it('should set loading = true', () => { + // Arrange + const state = { + ...initialState.pipelineAction, + loading: false, + error: '', + } + + // Act + const nextState = reducer(initialState, triggerPipelineActionSuccess()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + rdi: { + pipeline: nextState, + } + }) + expect(rdiPipelineActionSelector(rootState)).toEqual(state) + }) + }) + + describe('triggerPipelineActionFailure', () => { + it('should set loading = true', () => { + const error = 'Some reset error' + // Arrange + const state = { + ...initialState.pipelineAction, + loading: false, + error, + } + + // Act + const nextState = reducer(initialState, triggerPipelineActionFailure(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + rdi: { + pipeline: nextState, + } + }) + expect(rdiPipelineActionSelector(rootState)).toEqual(state) + }) + }) + // thunks describe('thunks', () => { describe('fetchRdiPipeline', () => { @@ -561,6 +637,154 @@ describe('rdi pipe slice', () => { }) }) + describe('resetPipelineAction', () => { + it('succeed to post data', async () => { + const cb = jest.fn() + const responsePayload = { status: 200 } + + apiService.post = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch( + resetPipelineAction('123', cb, cb) + ) + + // Assert + const expectedActions = [ + triggerPipelineAction(PipelineAction.Reset), + triggerPipelineActionSuccess(), + addMessageNotification(successMessages.SUCCESS_RESET_PIPELINE()), + ] + + expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) + }) + + it('failed to post data', async () => { + const cb = jest.fn() + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.post = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch( + resetPipelineAction('123', cb, cb) + ) + + // Assert + const expectedActions = [ + triggerPipelineAction(PipelineAction.Reset), + addErrorNotification(responsePayload as AxiosError), + triggerPipelineActionFailure(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + + describe('stopPipelineAction', () => { + it('succeed to post data', async () => { + const cb = jest.fn() + const responsePayload = { status: 200 } + + apiService.post = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch( + stopPipelineAction('123', cb, cb) + ) + + // Assert + const expectedActions = [ + triggerPipelineAction(PipelineAction.Stop), + triggerPipelineActionSuccess(), + ] + + expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) + }) + + it('failed to post data', async () => { + const cb = jest.fn() + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.post = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch( + stopPipelineAction('123', cb, cb) + ) + + // Assert + const expectedActions = [ + triggerPipelineAction(PipelineAction.Stop), + addErrorNotification(responsePayload as AxiosError), + triggerPipelineActionFailure(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + + describe('startPipelineAction', () => { + it('succeed to post data', async () => { + const cb = jest.fn() + const responsePayload = { status: 200 } + + apiService.post = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch( + startPipelineAction('123', cb, cb) + ) + + // Assert + const expectedActions = [ + triggerPipelineAction(PipelineAction.Start), + triggerPipelineActionSuccess(), + ] + + expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) + }) + + it('failed to post data', async () => { + const cb = jest.fn() + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.post = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch( + startPipelineAction('123', cb, cb) + ) + + // Assert + const expectedActions = [ + triggerPipelineAction(PipelineAction.Start), + addErrorNotification(responsePayload as AxiosError), + triggerPipelineActionFailure(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + describe('fetchRdiPipelineSchema', () => { it('succeed to fetch data', async () => { const data = { config: 'string' } diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index 4feca1156c..e6b80084ee 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -305,6 +305,12 @@ export enum TelemetryEvent { RDI_INSTANCE_SUBMITTED = 'RDI_INSTANCE_SUBMITTED', RDI_PIPELINE_UPLOAD_FROM_SERVER_CLICKED = 'RDI_PIPELINE_UPLOAD_FROM_SERVER_CLICKED', RDI_DEPLOY_CLICKED = 'RDI_DEPLOY_CLICKED', + RDI_PIPELINE_RESET_CLICKED = 'RDI_PIPELINE_RESET_CLICKED', + RDI_PIPELINE_RESET = 'RDI_PIPELINE_RESET', + RDI_PIPELINE_START_CLICKED = 'RDI_PIPELINE_START_CLICKED', + RDI_PIPELINE_STARTED = 'RDI_PIPELINE_STARTED', + RDI_PIPELINE_STOP_CLICKED = 'RDI_PIPELINE_STOP_CLICKED', + RDI_PIPELINE_STOPPED = 'RDI_PIPELINE_STOPPED', RDI_TEST_JOB_OPENED = 'RDI_TEST_JOB_OPENED', RDI_PIPELINE_DOWNLOAD_CLICKED = 'RDI_PIPELINE_DOWNLOAD_CLICKED', RDI_PIPELINE_UPLOAD_FROM_FILE_CLICKED = 'RDI_PIPELINE_UPLOAD_FROM_FILE_CLICKED', diff --git a/redisinsight/ui/src/templates/rdi-instance-page-template/RdiInstancePageTemplate.tsx b/redisinsight/ui/src/templates/rdi-instance-page-template/RdiInstancePageTemplate.tsx index 8dc76d7258..9978abe997 100644 --- a/redisinsight/ui/src/templates/rdi-instance-page-template/RdiInstancePageTemplate.tsx +++ b/redisinsight/ui/src/templates/rdi-instance-page-template/RdiInstancePageTemplate.tsx @@ -1,5 +1,4 @@ import React from 'react' -import RdiInstanceHeader from 'uiSrc/components/rdi-instance-header' import { ExplorePanelTemplate } from 'uiSrc/templates' import styles from './styles.module.scss' @@ -12,14 +11,12 @@ const RdiInstancePageTemplate = (props: Props) => { const { children } = props return ( -
- -
- - {children} - -
+
+ + {children} +
+ ) } diff --git a/redisinsight/ui/src/templates/rdi-instance-page-template/styles.module.scss b/redisinsight/ui/src/templates/rdi-instance-page-template/styles.module.scss index a7f3e8dbd6..063e5d3f27 100644 --- a/redisinsight/ui/src/templates/rdi-instance-page-template/styles.module.scss +++ b/redisinsight/ui/src/templates/rdi-instance-page-template/styles.module.scss @@ -4,5 +4,6 @@ } .content { - height: calc(100% - 60px); + height: calc(100% - 130px); + flex: 1 } diff --git a/redisinsight/ui/src/templates/rdi-pipeline-management-template/RdiPipelineManagementTemplate.tsx b/redisinsight/ui/src/templates/rdi-pipeline-management-template/RdiPipelineManagementTemplate.tsx index 63fee01b71..495d8ed0b3 100644 --- a/redisinsight/ui/src/templates/rdi-pipeline-management-template/RdiPipelineManagementTemplate.tsx +++ b/redisinsight/ui/src/templates/rdi-pipeline-management-template/RdiPipelineManagementTemplate.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Form } from 'formik' import RdiInstanceHeader from 'uiSrc/components/rdi-instance-header' -import RdiPipelineManagementHeader from 'uiSrc/pages/rdi/pipeline-management/components/header' +import RdiPipelineManagementHeader from 'uiSrc/pages/rdi/instance/components/header' import Navigation from 'uiSrc/pages/rdi/pipeline-management/components/navigation' import { ExplorePanelTemplate } from 'uiSrc/templates' diff --git a/redisinsight/ui/src/utils/apiResponse.ts b/redisinsight/ui/src/utils/apiResponse.ts index 4d328728c1..1b377a121a 100644 --- a/redisinsight/ui/src/utils/apiResponse.ts +++ b/redisinsight/ui/src/utils/apiResponse.ts @@ -29,6 +29,7 @@ export function getApiErrorMessage(error: AxiosError): string { if (isArray(errorMessage)) { return first(errorMessage) } + return errorMessage } diff --git a/tests/e2e/pageObjects/components/rdi/rdi-header.ts b/tests/e2e/pageObjects/components/rdi/rdi-header.ts index f6ca44dc64..7614fe151d 100644 --- a/tests/e2e/pageObjects/components/rdi/rdi-header.ts +++ b/tests/e2e/pageObjects/components/rdi/rdi-header.ts @@ -17,6 +17,12 @@ export class RdiHeader { importInput = Selector('[data-testid=import-file-modal-filepicker]'); confirmUploadingPipelineBatton = Selector('[data-testid=submit-btn]'); + resetPipelineButton = Selector('[data-testid=reset-pipeline-btn]'); + stopPipelineButton = Selector('[data-testid=stop-pipeline-btn]'); + startPipelineButton = Selector('[data-testid=start-pipeline-btn]'); + + pipelineStatus = Selector('[data-testid=pipeline-state-badge]'); + /** * Import pipeline * @param filePath the name if the file diff --git a/tests/e2e/tests/web/critical-path/rdi/deploy.e2e.ts b/tests/e2e/tests/web/critical-path/rdi/deploy.e2e.ts new file mode 100644 index 0000000000..b63ec7b990 --- /dev/null +++ b/tests/e2e/tests/web/critical-path/rdi/deploy.e2e.ts @@ -0,0 +1,188 @@ +import * as path from 'path'; +import { t } from 'testcafe'; +import * as yaml from 'js-yaml'; +import { RdiInstancePage } from '../../../../pageObjects/rdi-instance-page'; +import { AddNewRdiParameters, RdiApiRequests } from '../../../../helpers/api/api-rdi'; +import { cloudDatabaseConfig, commonUrl } from '../../../../helpers/conf'; +import { BrowserPage, MyRedisDatabasePage } from '../../../../pageObjects'; +import { RdiInstancesListPage } from '../../../../pageObjects/rdi-instances-list-page'; +import { + RdiPopoverOptions, + RedisOverviewPage +} from '../../../../helpers/constants'; +import { DatabaseHelper } from '../../../../helpers'; +import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; +import { RdiStatusPage } from '../../../../pageObjects/rdi-status-page'; + +const myRedisDatabasePage = new MyRedisDatabasePage(); +const rdiInstancePage = new RdiInstancePage(); +const rdiInstancesListPage = new RdiInstancesListPage(); +const rdiApiRequests = new RdiApiRequests(); +const databaseHelper = new DatabaseHelper(); +const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); +const rdiStatusPage = new RdiStatusPage(); + +const rdiInstance: AddNewRdiParameters = { + name: 'testInstance', + url: 'https://11.111.111.111', + username: 'username', + password: '111' +}; + +const cloudName = 'Redis-Stack-in-Redis-Enterprise-Cloud'; + +const filePath = path.join('..', '..', '..', '..', 'test-data', 'rdi', 'RDI_pipelineConfigurations.zip'); + +//skip the tests until rdi integration is added +fixture.skip `Deploy` + .meta({ type: 'critical_path', feature: 'rdi' }) + .page(commonUrl) + .beforeEach(async() => { + await databaseHelper.acceptLicenseTerms(); + await rdiApiRequests.addNewRdiApi(rdiInstance); + + }) + .afterEach(async() => { + await rdiApiRequests.deleteAllRdiApi(); + }); + +test.after(async() => { + + await t.click(browserPage.Cli.cliExpandButton); + await browserPage.Cli.sendCommandInCli('flushdb'); + + // Delete databases + await databaseAPIRequests.deleteAllDatabasesApi(); + await rdiApiRequests.deleteAllRdiApi(); +})('Verify that deploy and reset work', async() => { + await myRedisDatabasePage.setActivePage(RedisOverviewPage.DataBase); + await databaseHelper.autodiscoverRECloudDatabase( + cloudDatabaseConfig.accessKey, + cloudDatabaseConfig.secretKey + ); + const [host, port] = (await myRedisDatabasePage.hostPort.textContent).split(':'); + const password = cloudDatabaseConfig.databasePassword; + + const configData = { + sources: { + mysql: { + type: 'cdc', + logging: { + level: 'info' + }, + connection: { + type: 'mysql', + host: 'localhost', + port: 3306, + user: 'root', + password: '1111', + database: 'mydatabase' + } + } + }, + targets: { + target: { + connection: { + type: 'redis', + host, + port, + password: password + } + } + } + }; + const config = yaml.dump(configData, { indent: 2 }); + + // clear added db + await myRedisDatabasePage.clickOnDBByName(cloudName); + + await t.click(browserPage.Cli.cliExpandButton); + await browserPage.Cli.sendCommandInCli('flushdb'); + await t.click(browserPage.NavigationPanel.myRedisDBButton); + + //deploy pipeline + await myRedisDatabasePage.setActivePage(RedisOverviewPage.Rdi); + await rdiInstancesListPage.clickRdiByName(rdiInstance.name); + await rdiInstancePage.selectStartPipelineOption(RdiPopoverOptions.Pipeline); + await t.click(rdiInstancePage.templateCancelButton); + + await t.click(rdiInstancePage.configurationInput); + const lines = config.split('\n'); + // the variable shows the level of object depth for input by line in monaco + const maxLevelDepth = 4; + await rdiInstancePage.MonacoEditor.insertTextByLines(rdiInstancePage.configurationInput, lines, maxLevelDepth); + await t.click(rdiInstancePage.RdiHeader.deployPipelineBtn); + await t.click(rdiInstancePage.RdiHeader.deployConfirmBtn); + await t.expect(rdiInstancePage.loadingIndicator.exists).notOk({ timeout: 20000 }); + await t.click(rdiInstancePage.NavigationPanel.myRedisDBButton); + + //verify that keys are added + await rdiInstancePage.setActivePage(RedisOverviewPage.DataBase); + await myRedisDatabasePage.clickOnDBByName( + cloudName); + await t + .expect(browserPage.keysSummary.exists) + .ok('Key list not loaded', { timeout: 5000 }); + + await t.click(browserPage.Cli.cliExpandButton); + await browserPage.Cli.sendCommandInCli('flushdb'); + await t.click(browserPage.NavigationPanel.myRedisDBButton); + // reset pipeline + await myRedisDatabasePage.setActivePage(RedisOverviewPage.Rdi); + await rdiInstancesListPage.clickRdiByName(rdiInstance.name); + await rdiInstancePage.selectStartPipelineOption(RdiPopoverOptions.Server); + await t.click(rdiInstancePage.RdiHeader.resetPipelineButton); + + // keys is added again after reset + await rdiInstancePage.setActivePage(RedisOverviewPage.DataBase); + await myRedisDatabasePage.clickOnDBByName( + cloudName); + await t + .expect(browserPage.keysSummary.exists) + .ok('Key list not loaded', { timeout: 5000 }); +}); + +test('Verify stop and start works', async() => { + + await myRedisDatabasePage.setActivePage(RedisOverviewPage.Rdi); + await rdiInstancesListPage.clickRdiByName(rdiInstance.name); + + await rdiInstancePage.selectStartPipelineOption(RdiPopoverOptions.File); + await rdiInstancePage.RdiHeader.uploadPipeline(filePath); + await t.click(rdiInstancePage.RdiHeader.deployPipelineBtn); + await t.click(rdiInstancePage.RdiHeader.deployConfirmBtn); + await t.expect(rdiInstancePage.loadingIndicator.exists).notOk({ timeout: 20000 }); + + // verify stop button + await t.click(rdiInstancePage.RdiHeader.stopPipelineButton); + await t.expect(rdiInstancePage.RdiHeader.stopPipelineButton.hasAttribute('disabled')).ok('the stop button is not disabled'); + // wait until disabled + await t.expect(rdiInstancePage.RdiHeader.stopPipelineButton.hasAttribute('disabled')).notOk('the stop button is not disabled'); + await t.expect(rdiInstancePage.RdiHeader.startPipelineButton.hasAttribute('disabled')).notOk('the start button is not disabled'); + + await t.expect(rdiInstancePage.RdiHeader.stopPipelineButton.exists).notOk('the stop button is displayed'); + await t.expect(rdiInstancePage.RdiHeader.startPipelineButton.exists).ok('the start button is not displayed'); + + await t.click(rdiInstancePage.NavigationPanel.statusPageButton); + await t.expect(rdiStatusPage.refreshStreamsButton.exists).ok('status is not loaded'); + await t.click(rdiInstancePage.NavigationPanel.managementPageButton); + + // verify start button + await t.click(rdiInstancePage.RdiHeader.startPipelineButton); + await t.expect(rdiInstancePage.RdiHeader.startPipelineButton.hasAttribute('disabled')).ok('the start button is not disabled'); + // wait until disabled + await t.expect(rdiInstancePage.RdiHeader.startPipelineButton.hasAttribute('disabled')).notOk('the start button is not disabled'); + await t.expect(rdiInstancePage.RdiHeader.stopPipelineButton.hasAttribute('disabled')).notOk('the stop button is not disabled'); + + await t.expect(rdiInstancePage.RdiHeader.startPipelineButton.exists).notOk('the start button is displayed'); + await t.expect(rdiInstancePage.RdiHeader.stopPipelineButton.exists).ok('the stop button is not displayed'); + + await t.click(rdiInstancePage.NavigationPanel.statusPageButton); + await t.expect(rdiStatusPage.refreshStreamsButton.exists).notOk('status is loaded'); + + await t.expect(rdiInstancePage.RdiHeader.pipelineStatus.exists).ok('status is not displayed'); + + //TODO if it possible add a record to verify that the buttons work + +});