From ae0f48601d16d941c31f4c3a8264a754557e9f5b Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Wed, 19 Oct 2022 11:47:51 -0500 Subject: [PATCH 01/43] [Enterprise Search] introduce rich select for ml models (#143568) * [Enterprise Search] introduce rich select for ml models replaced the plane select for ml models with an EuiSuperSelect in the add inference pipeline modal. This is to give the user more context and useful information about the ML model when they are selecting it. * [Enterprise Search] fix badge rendering on small screens wrapped badges on the pipeline page in a span to stop them from going full width inside FlexItem's when using a small screen. --- .../pipelines/inference_pipeline_card.tsx | 18 ++++---- .../ingest_pipelines/custom_pipeline_item.tsx | 20 +++++---- .../default_pipeline_item.tsx | 14 +++--- .../ml_inference/configure_pipeline.tsx | 43 +++++++++++------- .../ml_inference/ml_inference_logic.ts | 4 +- .../ml_inference/model_select_option.tsx | 45 +++++++++++++++++++ 6 files changed, 103 insertions(+), 41 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/model_select_option.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.tsx index ab81e206daf5..a888364ac8bb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.tsx @@ -160,15 +160,15 @@ export const InferencePipelineCard: React.FC = (pipeline) => )} - {(modelType.length > 0 ? [modelType] : modelTypes).map((type) => ( - - - - {type} - - - - ))} + + + + + {modelType} + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/custom_pipeline_item.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/custom_pipeline_item.tsx index 54471840aff4..215983e5f40c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/custom_pipeline_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/custom_pipeline_item.tsx @@ -56,15 +56,17 @@ export const CustomPipelineItem: React.FC<{ - - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.processorsDescription', - { - defaultMessage: '{processorsCount} Processors', - values: { processorsCount }, - } - )} - + + + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.processorsDescription', + { + defaultMessage: '{processorsCount} Processors', + values: { processorsCount }, + } + )} + + diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/default_pipeline_item.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/default_pipeline_item.tsx index c22f7910bdc4..3e6ad933c3e8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/default_pipeline_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/default_pipeline_item.tsx @@ -75,12 +75,14 @@ export const DefaultPipelineItem: React.FC<{ )} - - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.managedBadge.label', - { defaultMessage: 'Managed' } - )} - + + + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.managedBadge.label', + { defaultMessage: 'Managed' } + )} + + diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx index fed9f8e6c537..868801116a04 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx @@ -17,6 +17,8 @@ import { EuiFormRow, EuiLink, EuiSelect, + EuiSuperSelect, + EuiSuperSelectOption, EuiSpacer, EuiText, } from '@elastic/eui'; @@ -29,6 +31,9 @@ import { docLinks } from '../../../../../shared/doc_links'; import { IndexViewLogic } from '../../index_view_logic'; import { MLInferenceLogic } from './ml_inference_logic'; +import { MlModelSelectOption } from './model_select_option'; + +const MODEL_SELECT_PLACEHOLDER_VALUE = 'model_placeholder$$'; const NoSourceFieldsError: React.FC = () => ( { const nameError = formErrors.pipelineName !== undefined && pipelineName.length > 0; const emptySourceFields = (sourceFields?.length ?? 0) === 0; + const modelOptions: Array> = [ + { + disabled: true, + inputDisplay: i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.model.placeholder', + { defaultMessage: 'Select a model' } + ), + value: MODEL_SELECT_PLACEHOLDER_VALUE, + }, + ...models.map((model) => ({ + dropdownDisplay: , + inputDisplay: model.model_id, + value: model.model_id, + })), + ]; + return ( <> @@ -134,27 +155,19 @@ export const ConfigurePipeline: React.FC = () => { )} fullWidth > - ({ text: m.model_id, value: m.model_id })), - ]} - onChange={(e) => + hasDividers + itemLayoutAlign="top" + onChange={(value) => setInferencePipelineConfiguration({ ...configuration, - modelID: e.target.value, + modelID: value, }) } + options={modelOptions} + valueOfSelected={modelID === '' ? MODEL_SELECT_PLACEHOLDER_VALUE : modelID} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts index 6488ca072314..5b9bd3364346 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts @@ -131,14 +131,14 @@ interface MLInferenceProcessorsValues { mappingData: typeof MappingsApiLogic.values.data; mappingStatus: Status; mlInferencePipeline?: MlInferencePipeline; - mlModelsData: typeof MLModelsApiLogic.values.data; + mlModelsData: TrainedModelConfigResponse[]; mlModelsStatus: Status; simulatePipelineData: typeof SimulateMlInterfacePipelineApiLogic.values.data; simulatePipelineErrors: string[]; simulatePipelineResult: IngestSimulateResponse; simulatePipelineStatus: Status; sourceFields: string[] | undefined; - supportedMLModels: typeof MLModelsApiLogic.values.data; + supportedMLModels: TrainedModelConfigResponse[]; } export const MLInferenceLogic = kea< diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/model_select_option.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/model_select_option.tsx new file mode 100644 index 000000000000..4529e85d720f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/model_select_option.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiTextColor, EuiTitle } from '@elastic/eui'; +import { TrainedModelConfigResponse } from '@kbn/ml-plugin/common/types/trained_models'; + +import { getMlModelTypesForModelConfig } from '../../../../../../../common/ml_inference_pipeline'; +import { getMLType, getModelDisplayTitle } from '../../../shared/ml_inference/utils'; + +export interface MlModelSelectOptionProps { + model: TrainedModelConfigResponse; +} +export const MlModelSelectOption: React.FC = ({ model }) => { + const type = getMLType(getMlModelTypesForModelConfig(model)); + const title = getModelDisplayTitle(type); + return ( + + + +

{title ?? model.model_id}

+
+
+ + + {title && ( + + {model.model_id} + + )} + + + {type} + + + + +
+ ); +}; From 9adcbe797e5bd895e8f1abbf2acf6a6a71deaa0f Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 19 Oct 2022 19:59:52 +0300 Subject: [PATCH 02/43] Fix flaky test (#143648) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../tests/trial/cases/assignees.ts | 114 +++++++++--------- 1 file changed, 54 insertions(+), 60 deletions(-) diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/assignees.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/assignees.ts index c2b53a008f43..574a843a858d 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/assignees.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/assignees.ts @@ -162,21 +162,19 @@ export default ({ getService }: FtrProviderContext): void => { auth: { user: superUser, space: 'space1' }, }); - const [_, caseWithDeleteAssignee1, caseWithDeleteAssignee2] = await Promise.all([ - createCase(supertest, postCaseReq), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profile[0].uid }], - }) - ), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profile[0].uid }], - }) - ), - ]); + await createCase(supertest, postCaseReq); + const caseWithDeleteAssignee1 = await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profile[0].uid }], + }) + ); + const caseWithDeleteAssignee2 = await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profile[0].uid }], + }) + ); const cases = await findCases({ supertest, @@ -202,21 +200,19 @@ export default ({ getService }: FtrProviderContext): void => { auth: { user: superUser, space: 'space1' }, }); - const [_, caseWithDeleteAssignee1, caseWithDeleteAssignee2] = await Promise.all([ - createCase(supertest, postCaseReq), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profileUidsToFilter[0].uid }], - }) - ), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profileUidsToFilter[1].uid }], - }) - ), - ]); + await createCase(supertest, postCaseReq); + const caseWithDeleteAssignee1 = await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profileUidsToFilter[0].uid }], + }) + ); + const caseWithDeleteAssignee2 = await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profileUidsToFilter[1].uid }], + }) + ); const cases = await findCases({ supertest, @@ -242,21 +238,20 @@ export default ({ getService }: FtrProviderContext): void => { auth: { user: superUser, space: 'space1' }, }); - const [caseWithNoAssignees] = await Promise.all([ - createCase(supertest, postCaseReq), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profile[0].uid }], - }) - ), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profile[0].uid }], - }) - ), - ]); + const caseWithNoAssignees = await createCase(supertest, postCaseReq); + await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profile[0].uid }], + }) + ); + + await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profile[0].uid }], + }) + ); const cases = await findCases({ supertest, @@ -282,21 +277,20 @@ export default ({ getService }: FtrProviderContext): void => { auth: { user: superUser, space: 'space1' }, }); - const [caseWithNoAssignees, caseWithDeleteAssignee1] = await Promise.all([ - createCase(supertest, postCaseReq), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profileUidsToFilter[0].uid }], - }) - ), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profileUidsToFilter[1].uid }], - }) - ), - ]); + const caseWithNoAssignees = await createCase(supertest, postCaseReq); + const caseWithDeleteAssignee1 = await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profileUidsToFilter[0].uid }], + }) + ); + + await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profileUidsToFilter[1].uid }], + }) + ); const cases = await findCases({ supertest, From 4cc3b54f6de804465d0f08baf52971568ca824ad Mon Sep 17 00:00:00 2001 From: Adam Demjen Date: Wed, 19 Oct 2022 13:27:32 -0400 Subject: [PATCH 03/43] [8.6][ML] New API to get inference processor pipelines (#143564) * Add endpoint to fetch ML inference pipelines --- ...h_ml_inference_pipeline_processors.test.ts | 2 +- .../get_inference_pipelines.test.ts | 131 ++++++++++++++++++ .../get_inference_pipelines.ts | 75 ++++++++++ .../routes/enterprise_search/indices.test.ts | 64 +++++++++ .../routes/enterprise_search/indices.ts | 24 ++++ 5 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.ts diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/fetch_ml_inference_pipeline_processors.test.ts b/x-pack/plugins/enterprise_search/server/lib/indices/fetch_ml_inference_pipeline_processors.test.ts index bc77b2dff782..8e1caa17e2b7 100644 --- a/x-pack/plugins/enterprise_search/server/lib/indices/fetch_ml_inference_pipeline_processors.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/indices/fetch_ml_inference_pipeline_processors.test.ts @@ -577,7 +577,7 @@ describe('fetchMlInferencePipelineProcessors lib function', () => { }); describe('when Machine Learning is disabled in the current space', () => { - it('should throw an eror', () => { + it('should throw an error', () => { expect(() => fetchMlInferencePipelineProcessors( mockClient as unknown as ElasticsearchClient, diff --git a/x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.test.ts b/x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.test.ts new file mode 100644 index 000000000000..45953166667a --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IngestProcessorContainer } from '@elastic/elasticsearch/lib/api/types'; +import { ElasticsearchClient } from '@kbn/core/server'; +import { MlTrainedModels } from '@kbn/ml-plugin/server'; + +import { getMlInferencePipelines } from './get_inference_pipelines'; + +jest.mock('../indices/fetch_ml_inference_pipeline_processors', () => ({ + getMlModelConfigsForModelIds: jest.fn(), +})); + +describe('getMlInferencePipelines', () => { + const mockClient = { + ingest: { + getPipeline: jest.fn(), + }, + }; + const mockTrainedModelsProvider = { + getTrainedModels: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should throw an error if Machine Learning is disabled in the current space', () => { + expect(() => + getMlInferencePipelines(mockClient as unknown as ElasticsearchClient, undefined) + ).rejects.toThrowError('Machine Learning is not enabled'); + }); + + it('should fetch inference pipelines and redact inaccessible model IDs', async () => { + function mockInferencePipeline(modelId: string) { + return { + processors: [ + { + append: {}, + }, + { + inference: { + model_id: modelId, + }, + }, + { + remove: {}, + }, + ], + }; + } + + const mockPipelines = { + pipeline1: mockInferencePipeline('model1'), + pipeline2: mockInferencePipeline('model2'), + pipeline3: mockInferencePipeline('redactedModel3'), + pipeline4: { + // Pipeline with multiple inference processors referencing an inaccessible model + processors: [ + { + append: {}, + }, + { + inference: { + model_id: 'redactedModel3', + }, + }, + { + inference: { + model_id: 'model2', + }, + }, + { + inference: { + model_id: 'redactedModel4', + }, + }, + { + remove: {}, + }, + ], + }, + }; + + const mockTrainedModels = { + trained_model_configs: [ + { + model_id: 'model1', + }, + { + model_id: 'model2', + }, + ], + }; + + mockClient.ingest.getPipeline.mockImplementation(() => Promise.resolve(mockPipelines)); + mockTrainedModelsProvider.getTrainedModels.mockImplementation(() => + Promise.resolve(mockTrainedModels) + ); + + const actualPipelines = await getMlInferencePipelines( + mockClient as unknown as ElasticsearchClient, + mockTrainedModelsProvider as unknown as MlTrainedModels + ); + + expect( + (actualPipelines.pipeline1.processors as IngestProcessorContainer[])[1].inference?.model_id + ).toBeDefined(); + expect( + (actualPipelines.pipeline2.processors as IngestProcessorContainer[])[1].inference?.model_id + ).toBeDefined(); + expect( + (actualPipelines.pipeline3.processors as IngestProcessorContainer[])[1].inference?.model_id + ).toEqual(''); // Redacted model ID + expect( + (actualPipelines.pipeline4.processors as IngestProcessorContainer[])[1].inference?.model_id + ).toEqual(''); + expect( + (actualPipelines.pipeline4.processors as IngestProcessorContainer[])[2].inference?.model_id + ).toBeDefined(); + expect( + (actualPipelines.pipeline4.processors as IngestProcessorContainer[])[3].inference?.model_id + ).toEqual(''); + expect(mockClient.ingest.getPipeline).toHaveBeenCalledWith({ id: 'ml-inference-*' }); + expect(mockTrainedModelsProvider.getTrainedModels).toHaveBeenCalledWith({}); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.ts b/x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.ts new file mode 100644 index 000000000000..4bdf7e95d4a0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IngestPipeline, IngestProcessorContainer } from '@elastic/elasticsearch/lib/api/types'; +import { ElasticsearchClient } from '@kbn/core/server'; +import { MlTrainedModels } from '@kbn/ml-plugin/server'; + +/** + * Gets all ML inference pipelines. Redacts trained model IDs in those pipelines which reference + * a model inaccessible in the current Kibana space. + * @param esClient the Elasticsearch Client to use to fetch the errors. + * @param trainedModelsProvider ML trained models provider. + */ +export const getMlInferencePipelines = async ( + esClient: ElasticsearchClient, + trainedModelsProvider: MlTrainedModels | undefined +): Promise> => { + if (!trainedModelsProvider) { + return Promise.reject(new Error('Machine Learning is not enabled')); + } + + // Fetch all ML inference pipelines and trained models that are accessible in the current + // Kibana space + const [fetchedInferencePipelines, trainedModels] = await Promise.all([ + esClient.ingest.getPipeline({ + id: 'ml-inference-*', + }), + trainedModelsProvider.getTrainedModels({}), + ]); + const accessibleModelIds = Object.values(trainedModels.trained_model_configs).map( + (modelConfig) => modelConfig.model_id + ); + + // Process pipelines: check if the model_id is one of the redacted ones, if so, redact it in the + // result as well + const inferencePipelinesResult: Record = {}; + Object.entries(fetchedInferencePipelines).forEach(([name, inferencePipeline]) => { + inferencePipelinesResult[name] = { + ...inferencePipeline, + processors: inferencePipeline.processors?.map((processor) => + redactModelIdIfInaccessible(processor, accessibleModelIds) + ), + }; + }); + + return Promise.resolve(inferencePipelinesResult); +}; + +/** + * Convenience function to redact the trained model ID in an ML inference processor if the model is + * not accessible in the current Kibana space. In this case `model_id` gets replaced with `''`. + * @param processor the processor to process. + * @param accessibleModelIds array of known accessible model IDs. + * @returns the input processor if unchanged, or a copy of the processor with the model ID redacted. + */ +function redactModelIdIfInaccessible( + processor: IngestProcessorContainer, + accessibleModelIds: string[] +): IngestProcessorContainer { + if (!processor.inference || accessibleModelIds.includes(processor.inference.model_id)) { + return processor; + } + + return { + ...processor, + inference: { + ...processor.inference, + model_id: '', + }, + }; +} diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts index d0a652b12d9c..371098e180a6 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts @@ -32,12 +32,16 @@ jest.mock('../../lib/indices/exists_index', () => ({ jest.mock('../../lib/ml_inference_pipeline/get_inference_errors', () => ({ getMlInferenceErrors: jest.fn(), })); +jest.mock('../../lib/ml_inference_pipeline/get_inference_pipelines', () => ({ + getMlInferencePipelines: jest.fn(), +})); import { deleteMlInferencePipeline } from '../../lib/indices/delete_ml_inference_pipeline'; import { indexOrAliasExists } from '../../lib/indices/exists_index'; import { fetchMlInferencePipelineHistory } from '../../lib/indices/fetch_ml_inference_pipeline_history'; import { fetchMlInferencePipelineProcessors } from '../../lib/indices/fetch_ml_inference_pipeline_processors'; import { getMlInferenceErrors } from '../../lib/ml_inference_pipeline/get_inference_errors'; +import { getMlInferencePipelines } from '../../lib/ml_inference_pipeline/get_inference_pipelines'; import { createAndReferenceMlInferencePipeline } from '../../utils/create_ml_inference_pipeline'; import { ElasticsearchResponseError } from '../../utils/identify_exceptions'; @@ -642,4 +646,64 @@ describe('Enterprise Search Managed Indices', () => { }); }); }); + + describe('GET /internal/enterprise_search/pipelines/ml_inference', () => { + let mockTrainedModelsProvider: MlTrainedModels; + let mockMl: SharedServices; + + beforeEach(() => { + const context = { + core: Promise.resolve(mockCore), + } as unknown as jest.Mocked; + + mockRouter = new MockRouter({ + context, + method: 'get', + path: '/internal/enterprise_search/pipelines/ml_inference', + }); + + mockTrainedModelsProvider = { + getTrainedModels: jest.fn(), + getTrainedModelsStats: jest.fn(), + } as MlTrainedModels; + + mockMl = { + trainedModelsProvider: () => Promise.resolve(mockTrainedModelsProvider), + } as unknown as jest.Mocked; + + registerIndexRoutes({ + ...mockDependencies, + router: mockRouter.router, + ml: mockMl, + }); + }); + + it('fetches ML inference pipelines', async () => { + const pipelinesResult = { + pipeline1: { + processors: [], + }, + pipeline2: { + processors: [], + }, + pipeline3: { + processors: [], + }, + }; + + (getMlInferencePipelines as jest.Mock).mockResolvedValueOnce(pipelinesResult); + + await mockRouter.callRoute({}); + + expect(getMlInferencePipelines).toHaveBeenCalledWith( + mockClient.asCurrentUser, + mockTrainedModelsProvider + ); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: pipelinesResult, + headers: { 'content-type': 'application/json' }, + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts index e1d2f0238740..a8dd969376b7 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts @@ -30,6 +30,7 @@ import { fetchMlInferencePipelineHistory } from '../../lib/indices/fetch_ml_infe import { fetchMlInferencePipelineProcessors } from '../../lib/indices/fetch_ml_inference_pipeline_processors'; import { generateApiKey } from '../../lib/indices/generate_api_key'; import { getMlInferenceErrors } from '../../lib/ml_inference_pipeline/get_inference_errors'; +import { getMlInferencePipelines } from '../../lib/ml_inference_pipeline/get_inference_pipelines'; import { createIndexPipelineDefinitions } from '../../lib/pipelines/create_pipeline_definitions'; import { getCustomPipelines } from '../../lib/pipelines/get_custom_pipelines'; import { getPipeline } from '../../lib/pipelines/get_pipeline'; @@ -680,4 +681,27 @@ export function registerIndexRoutes({ }); }) ); + + router.get( + { + path: '/internal/enterprise_search/pipelines/ml_inference', + validate: {}, + }, + elasticsearchErrorHandler(log, async (context, request, response) => { + const { + elasticsearch: { client }, + savedObjects: { client: savedObjectsClient }, + } = await context.core; + const trainedModelsProvider = ml + ? await ml.trainedModelsProvider(request, savedObjectsClient) + : undefined; + + const pipelines = await getMlInferencePipelines(client.asCurrentUser, trainedModelsProvider); + + return response.ok({ + body: pipelines, + headers: { 'content-type': 'application/json' }, + }); + }) + ); } From 348ed233c3d696509e93e15731a643e41fd78423 Mon Sep 17 00:00:00 2001 From: Wafaa Nasr Date: Wed, 19 Oct 2022 19:33:17 +0200 Subject: [PATCH 04/43] [Security Solution][Exceptions] - Create/ refactor Exception-List common's components in @kbn/securitysolution-exception-list-components (#143363) * feat: add list header components + refactoring * add tests for comments and conditions components * remove unused var * complete tests for exception_item_card, excpetion_items, empty_viewer_state * add test for useExceptionItemCard hook * add tests for generateLinedRulesMenuItems * add readme and index.md * Update index.md * remove unused file * remove unused file * add tests for Header_menu * extract security mocks to a file * test for header * add missing tests * fix tests * fix text_with_edit dataTestSubj * apply rewview comment Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../README.md | 18 +- .../index.ts | 8 +- .../jest.config.js | 7 + .../empty_viewer_state.test.tsx | 50 +- .../{empty_viewer_state.tsx => index.tsx} | 0 .../__snapshots__/comments.test.tsx.snap | 667 +++++++ .../comments/comments.test.tsx | 46 + .../comments/{comments.tsx => index.tsx} | 43 +- .../conditions/conditions.test.tsx | 2 +- .../__snapshots__/entry_content.test.tsx.snap | 164 ++ .../entry_content.helper.test.tsx | 97 + .../entry_content/entry_content.helper.tsx | 7 +- .../entry_content/entry_content.test.tsx | 44 + .../{entry_content.tsx => index.tsx} | 28 +- .../conditions/{conditions.tsx => index.tsx} | 4 +- .../__snapshots__/os_conditions.test.tsx.snap | 467 +++++ .../{os_conditions.tsx => index.tsx} | 10 +- .../os_conditions/os_conditions.test.tsx | 43 + ....test.tsx => exception_item_card.test.tsx} | 96 +- .../exception_item_card.tsx | 73 +- .../header/header.test.tsx | 34 +- .../src/exception_item_card/header/header.tsx | 83 - .../src/exception_item_card/header/index.tsx | 44 + .../src/exception_item_card/index.ts | 8 +- .../__snapshots__/details_info.test.tsx.snap | 411 ++++ .../meta/details_info/details_info.test.tsx | 37 + .../{details_info.tsx => index.tsx} | 17 +- .../src/exception_item_card/meta/index.tsx | 97 + .../exception_item_card/meta/meta.test.tsx | 123 +- .../src/exception_item_card/meta/meta.tsx | 123 -- .../use_exception_item_card.test.ts | 122 ++ .../use_exception_item_card.ts | 81 + .../exception_items/exception_items.test.tsx | 183 +- .../{exception_items.tsx => index.tsx} | 31 +- ...erate_linked_rules_menu_item.test.tsx.snap | 267 +++ .../generate_linked_rules_menu_item.test.tsx | 60 + .../generate_linked_rules_menu_item/index.tsx | 49 + .../menu_link.styles.ts | 18 + .../__snapshots__/header_menu.test.tsx.snap | 626 ++++++ .../src/header_menu/header_menu.test.tsx | 106 + .../src/header_menu/index.tsx | 125 ++ .../__snapshots__/list_header.test.tsx.snap | 1711 +++++++++++++++++ .../__snapshots__/edit_modal.test.tsx.snap | 227 +++ .../edit_modal/edit_modal.test.tsx | 71 + .../src/list_header/edit_modal/index.tsx | 99 + .../src/list_header/index.tsx | 126 ++ .../src/list_header/list_header.styles.ts | 37 + .../src/list_header/list_header.test.tsx | 104 + .../__snapshots__/menu_items.test.tsx.snap | 248 +++ .../src/list_header/menu_items/index.tsx | 112 ++ .../menu_items/menu_items.test.tsx | 81 + .../src/list_header/use_list_header.test.ts | 92 + .../src/list_header/use_list_header.ts | 42 + .../comments.mock.tsx} | 7 + .../src/mocks/entry.mock.ts | 29 + .../exception_list_item_schema.mock.ts | 0 .../src/mocks/header.mock.ts | 23 + .../src/mocks/rule_references.mock.ts | 42 + .../mocks/security_link_component.mock.tsx | 36 + .../search_bar/{search_bar.tsx => index.tsx} | 9 +- .../src/search_bar/search_bar.test.tsx | 2 +- .../text_with_edit.test.tsx.snap | 189 ++ .../src/text_with_edit/index.tsx | 49 + .../text_with_edit/text_with_edit.test.tsx | 60 + .../src/translations.ts | 85 + .../src/types/index.ts | 19 +- 66 files changed, 7510 insertions(+), 509 deletions(-) rename packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/{empty_viewer_state.tsx => index.tsx} (100%) create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/__snapshots__/comments.test.tsx.snap create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.test.tsx rename packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/{comments.tsx => index.tsx} (54%) create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/__snapshots__/entry_content.test.tsx.snap create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.test.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.test.tsx rename packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/{entry_content.tsx => index.tsx} (79%) rename packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/{conditions.tsx => index.tsx} (94%) create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/__snapshots__/os_conditions.test.tsx.snap rename packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/{os_conditions.tsx => index.tsx} (79%) create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.test.tsx rename packages/kbn-securitysolution-exception-list-components/src/exception_item_card/{index.test.tsx => exception_item_card.test.tsx} (65%) delete mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/index.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/__snapshots__/details_info.test.tsx.snap create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.test.tsx rename packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/{details_info.tsx => index.tsx} (85%) create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/index.tsx delete mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.test.ts create mode 100644 packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.ts rename packages/kbn-securitysolution-exception-list-components/src/exception_items/{exception_items.tsx => index.tsx} (75%) create mode 100644 packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/__snapshots__/generate_linked_rules_menu_item.test.tsx.snap create mode 100644 packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/generate_linked_rules_menu_item.test.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/index.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/menu_link.styles.ts create mode 100644 packages/kbn-securitysolution-exception-list-components/src/header_menu/__snapshots__/header_menu.test.tsx.snap create mode 100644 packages/kbn-securitysolution-exception-list-components/src/header_menu/header_menu.test.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/header_menu/index.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/list_header/__snapshots__/list_header.test.tsx.snap create mode 100644 packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/__snapshots__/edit_modal.test.tsx.snap create mode 100644 packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/edit_modal.test.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/index.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/list_header/index.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.styles.ts create mode 100644 packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.test.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/__snapshots__/menu_items.test.tsx.snap create mode 100644 packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/index.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/menu_items.test.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.test.ts create mode 100644 packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.ts rename packages/kbn-securitysolution-exception-list-components/src/{test_helpers/comments.mock.ts => mocks/comments.mock.tsx} (78%) create mode 100644 packages/kbn-securitysolution-exception-list-components/src/mocks/entry.mock.ts rename packages/kbn-securitysolution-exception-list-components/src/{test_helpers => mocks}/exception_list_item_schema.mock.ts (100%) create mode 100644 packages/kbn-securitysolution-exception-list-components/src/mocks/header.mock.ts create mode 100644 packages/kbn-securitysolution-exception-list-components/src/mocks/rule_references.mock.ts create mode 100644 packages/kbn-securitysolution-exception-list-components/src/mocks/security_link_component.mock.tsx rename packages/kbn-securitysolution-exception-list-components/src/search_bar/{search_bar.tsx => index.tsx} (92%) create mode 100644 packages/kbn-securitysolution-exception-list-components/src/text_with_edit/__snapshots__/text_with_edit.test.tsx.snap create mode 100644 packages/kbn-securitysolution-exception-list-components/src/text_with_edit/index.tsx create mode 100644 packages/kbn-securitysolution-exception-list-components/src/text_with_edit/text_with_edit.test.tsx diff --git a/packages/kbn-securitysolution-exception-list-components/README.md b/packages/kbn-securitysolution-exception-list-components/README.md index e23b85e40996..fe23c66e8988 100644 --- a/packages/kbn-securitysolution-exception-list-components/README.md +++ b/packages/kbn-securitysolution-exception-list-components/README.md @@ -1,11 +1,11 @@ # @kbn/securitysolution-exception-list-components -This is where the building UI components of the Exception-List live -Most of the components here are imported from `x-pack/plugins/security_solutions/public/detection_engine` +Common exceptions' components # Aim -TODO +- To have most of the Exceptions' components in one place, to be shared accross multiple pages and used for different logic. +- This `package` holds the presetational part of the components only as the API or the logic part should reside under the consumer page # Pattern used @@ -14,9 +14,19 @@ component index.tsx index.styles.ts <-- to hold styles if the component has many custom styles use_component.ts <-- for logic if the Presentational Component has logic - index.test.tsx + component.test.tsx use_component.test.tsx + ``` +# Testing + +In order to unify our testing tools, we configured only two libraries, the `React-Testing-Library` to test the component UI part and the `Reat-Testing-Hooks` to test the component's UI interactions + +# Styling + +In order to follow the `KBN-Packages's` recommendations, to define a custom CSS we can only use the `@emotion/react` or `@emotion/css` libraries + + # Next diff --git a/packages/kbn-securitysolution-exception-list-components/index.ts b/packages/kbn-securitysolution-exception-list-components/index.ts index f5001ff35fd3..6b11782b574d 100644 --- a/packages/kbn-securitysolution-exception-list-components/index.ts +++ b/packages/kbn-securitysolution-exception-list-components/index.ts @@ -6,11 +6,13 @@ * Side Public License, v 1. */ -export * from './src/search_bar/search_bar'; -export * from './src/empty_viewer_state/empty_viewer_state'; +export * from './src/search_bar'; +export * from './src/empty_viewer_state'; export * from './src/pagination/pagination'; // export * from './src/exceptions_utility/exceptions_utility'; -export * from './src/exception_items/exception_items'; +export * from './src/exception_items'; export * from './src/exception_item_card'; export * from './src/value_with_space_warning'; export * from './src/types'; +export * from './src/list_header'; +export * from './src/header_menu'; diff --git a/packages/kbn-securitysolution-exception-list-components/jest.config.js b/packages/kbn-securitysolution-exception-list-components/jest.config.js index 37a11c23c75b..00f407dce42f 100644 --- a/packages/kbn-securitysolution-exception-list-components/jest.config.js +++ b/packages/kbn-securitysolution-exception-list-components/jest.config.js @@ -14,6 +14,13 @@ module.exports = { collectCoverageFrom: [ '/packages/kbn-securitysolution-exception-list-components/**/*.{ts,tsx}', '!/packages/kbn-securitysolution-exception-list-components/**/*.test', + '!/packages/kbn-securitysolution-exception-list-components/**/types/*', + '!/packages/kbn-securitysolution-exception-list-components/**/*.type', + '!/packages/kbn-securitysolution-exception-list-components/**/*.styles', + '!/packages/kbn-securitysolution-exception-list-components/**/mocks/*', + '!/packages/kbn-securitysolution-exception-list-components/**/*.config', + '!/packages/kbn-securitysolution-exception-list-components/**/translations', + '!/packages/kbn-securitysolution-exception-list-components/**/types/*', ], setupFilesAfterEnv: [ '/packages/kbn-securitysolution-exception-list-components/setup_test.ts', diff --git a/packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/empty_viewer_state.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/empty_viewer_state.test.tsx index 43943e0e8fb9..3e883a0162b6 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/empty_viewer_state.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/empty_viewer_state.test.tsx @@ -9,8 +9,9 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { EmptyViewerState } from './empty_viewer_state'; +import { EmptyViewerState } from '.'; import { ListTypeText, ViewerStatus } from '../types'; +import * as i18n from '../translations'; describe('EmptyViewerState', () => { it('it should render "error" with the default title and body', () => { @@ -23,10 +24,10 @@ describe('EmptyViewerState', () => { ); expect(wrapper.getByTestId('errorViewerState')).toBeTruthy(); - expect(wrapper.getByTestId('errorTitle')).toHaveTextContent('Unable to load exception items'); - expect(wrapper.getByTestId('errorBody')).toHaveTextContent( - 'There was an error loading the exception items. Contact your administrator for help.' + expect(wrapper.getByTestId('errorTitle')).toHaveTextContent( + i18n.EMPTY_VIEWER_STATE_ERROR_TITLE ); + expect(wrapper.getByTestId('errorBody')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_ERROR_BODY); }); it('it should render "error" when sending the title and body props', () => { const wrapper = render( @@ -65,9 +66,11 @@ describe('EmptyViewerState', () => { expect(wrapper.getByTestId('emptySearchViewerState')).toBeTruthy(); expect(wrapper.getByTestId('emptySearchTitle')).toHaveTextContent( - 'No results match your search criteria' + i18n.EMPTY_VIEWER_STATE_EMPTY_SEARCH_TITLE + ); + expect(wrapper.getByTestId('emptySearchBody')).toHaveTextContent( + i18n.EMPTY_VIEWER_STATE_EMPTY_SEARCH_BODY ); - expect(wrapper.getByTestId('emptySearchBody')).toHaveTextContent('Try modifying your search'); }); it('it should render empty search when sending title and body props', () => { const wrapper = render( @@ -111,11 +114,11 @@ describe('EmptyViewerState', () => { const { getByTestId } = wrapper; expect(getByTestId('emptyViewerState')).toBeTruthy(); - expect(getByTestId('emptyTitle')).toHaveTextContent('Add exceptions to this rule'); - expect(getByTestId('emptyBody')).toHaveTextContent( - 'There is no exception in your rule. Create your first rule exception.' + expect(getByTestId('emptyTitle')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_EMPTY_TITLE); + expect(getByTestId('emptyBody')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_EMPTY_BODY); + expect(getByTestId('emptyStateButton')).toHaveTextContent( + i18n.EMPTY_VIEWER_STATE_EMPTY_VIEWER_BUTTON('rule') ); - expect(getByTestId('emptyStateButton')).toHaveTextContent('Create rule exception'); }); it('it should render no items screen with default title and body props and listType endPoint', () => { const wrapper = render( @@ -129,10 +132,29 @@ describe('EmptyViewerState', () => { const { getByTestId } = wrapper; expect(getByTestId('emptyViewerState')).toBeTruthy(); - expect(getByTestId('emptyTitle')).toHaveTextContent('Add exceptions to this rule'); - expect(getByTestId('emptyBody')).toHaveTextContent( - 'There is no exception in your rule. Create your first rule exception.' + expect(getByTestId('emptyTitle')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_EMPTY_TITLE); + expect(getByTestId('emptyBody')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_EMPTY_BODY); + expect(getByTestId('emptyStateButton')).toHaveTextContent( + i18n.EMPTY_VIEWER_STATE_EMPTY_VIEWER_BUTTON(ListTypeText.ENDPOINT) + ); + }); + it('it should render no items screen and disable the Create exception button if isReadOnly true', () => { + const wrapper = render( + + ); + + const { getByTestId } = wrapper; + expect(getByTestId('emptyViewerState')).toBeTruthy(); + expect(getByTestId('emptyTitle')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_EMPTY_TITLE); + expect(getByTestId('emptyBody')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_EMPTY_BODY); + expect(getByTestId('emptyStateButton')).toHaveTextContent( + i18n.EMPTY_VIEWER_STATE_EMPTY_VIEWER_BUTTON(ListTypeText.ENDPOINT) ); - expect(getByTestId('emptyStateButton')).toHaveTextContent('Create endpoint exception'); + expect(getByTestId('emptyStateButton')).toBeDisabled(); }); }); diff --git a/packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/empty_viewer_state.tsx b/packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/index.tsx similarity index 100% rename from packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/empty_viewer_state.tsx rename to packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/index.tsx diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/__snapshots__/comments.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/__snapshots__/comments.test.tsx.snap new file mode 100644 index 000000000000..5c0dba4573e0 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/__snapshots__/comments.test.tsx.snap @@ -0,0 +1,667 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExceptionItemCardComments should render comments panel closed 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+
+
    +
  1. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  2. +
  3. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  4. +
+
+
+
+
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
+
+
    +
  1. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  2. +
  3. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  4. +
+
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ExceptionItemCardComments should render comments panel opened when accordion is clicked 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+
+
    +
  1. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  2. +
  3. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  4. +
+
+
+
+
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
+
+
    +
  1. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  2. +
  3. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  4. +
+
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.test.tsx new file mode 100644 index 000000000000..64b2d1c9e3a8 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { mockGetFormattedComments } from '../../mocks/comments.mock'; +import { ExceptionItemCardComments } from '.'; +import * as i18n from '../translations'; + +const comments = mockGetFormattedComments(); +describe('ExceptionItemCardComments', () => { + it('should render comments panel closed', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.getByTestId('ExceptionItemCardCommentsContainer')).toHaveTextContent( + i18n.exceptionItemCardCommentsAccordion(comments.length) + ); + expect(wrapper.getByTestId('accordionContentPanel')).not.toBeVisible(); + }); + + it('should render comments panel opened when accordion is clicked', () => { + const wrapper = render( + + ); + + const container = wrapper.getByTestId('ExceptionItemCardCommentsContainerTextButton'); + fireEvent.click(container); + expect(wrapper.getByTestId('accordionContentPanel')).toBeVisible(); + expect(wrapper.getByTestId('accordionCommentList')).toBeVisible(); + expect(wrapper.getByTestId('accordionCommentList')).toHaveTextContent('some old comment'); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/index.tsx similarity index 54% rename from packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/index.tsx index ca08d10f0a04..8d697fd6e1f8 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/index.tsx @@ -19,27 +19,30 @@ const accordionCss = css` export interface ExceptionItemCardCommentsProps { comments: EuiCommentProps[]; + dataTestSubj?: string; } -export const ExceptionItemCardComments = memo(({ comments }) => { - return ( - - - {i18n.exceptionItemCardCommentsAccordion(comments.length)} -
- } - arrowDisplay="none" - data-test-subj="exceptionsViewerCommentAccordion" - > - - - - - - ); -}); +export const ExceptionItemCardComments = memo( + ({ comments, dataTestSubj }) => { + return ( + + + {i18n.exceptionItemCardCommentsAccordion(comments.length)} + + } + arrowDisplay="none" + data-test-subj="exceptionItemCardComments" + > + + + + + + ); + } +); ExceptionItemCardComments.displayName = 'ExceptionItemCardComments'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.test.tsx index ae4b76a4a7dc..73eb10fce68d 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.test.tsx @@ -9,7 +9,7 @@ import { render } from '@testing-library/react'; import React from 'react'; -import { ExceptionItemCardConditions } from './conditions'; +import { ExceptionItemCardConditions } from '.'; interface TestEntry { field: string; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/__snapshots__/entry_content.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/__snapshots__/entry_content.test.tsx.snap new file mode 100644 index 000000000000..4f35694645fd --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/__snapshots__/entry_content.test.tsx.snap @@ -0,0 +1,164 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EntryContent should render a nested value 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ + + +
+ + + + + + + + included in + + + + list_id + + +
+
+
+
+
+ , + "container":
+
+
+
+ + + +
+ + + + + + + + included in + + + + list_id + + +
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.test.tsx new file mode 100644 index 000000000000..d30cf9fa2f1d --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { ListOperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { OPERATOR_TYPE_LABELS_EXCLUDED, OPERATOR_TYPE_LABELS_INCLUDED } from '../conditions.config'; +import { getEntryOperator, getValue, getValueExpression } from './entry_content.helper'; +import { render } from '@testing-library/react'; +import { + includedExistsTypeEntry, + includedListTypeEntry, + includedMatchTypeEntry, +} from '../../../mocks/entry.mock'; + +describe('entry_content.helper', () => { + describe('getEntryOperator', () => { + it('should return empty if type is nested', () => { + const result = getEntryOperator(ListOperatorTypeEnum.NESTED, 'included'); + expect(result).toBeFalsy(); + expect(result).toEqual(''); + }); + it('should return the correct labels for OPERATOR_TYPE_LABELS_INCLUDED when operator is included', () => { + const allKeys = Object.keys(OPERATOR_TYPE_LABELS_INCLUDED); + const [, ...withoutNested] = allKeys; + withoutNested.forEach((key) => { + const result = getEntryOperator(key as ListOperatorTypeEnum, 'included'); + const expectedLabel = OPERATOR_TYPE_LABELS_INCLUDED[key as ListOperatorTypeEnum]; + expect(result).toEqual(expectedLabel); + }); + }); + it('should return the correct labels for OPERATOR_TYPE_LABELS_EXCLUDED when operator is excluded', () => { + const allKeys = Object.keys(OPERATOR_TYPE_LABELS_EXCLUDED); + const [, ...withoutNested] = allKeys; + withoutNested.forEach((key) => { + const result = getEntryOperator(key as ListOperatorTypeEnum, 'excluded'); + const expectedLabel = + OPERATOR_TYPE_LABELS_EXCLUDED[ + key as Exclude + ]; + expect(result).toEqual(expectedLabel); + }); + }); + it('should return the type when it is neither OPERATOR_TYPE_LABELS_INCLUDED nor OPERATOR_TYPE_LABELS_EXCLUDED', () => { + const result = getEntryOperator('test' as ListOperatorTypeEnum, 'included'); + expect(result).toEqual('test'); + }); + }); + describe('getValue', () => { + it('should return list.id when entry type is "list"', () => { + expect(getValue(includedListTypeEntry)).toEqual('list_id'); + }); + it('should return value when entry type is not "list"', () => { + expect(getValue(includedMatchTypeEntry)).toEqual('matches value'); + }); + it('should return empty string when type does not have value', () => { + expect(getValue(includedExistsTypeEntry)).toEqual(''); + }); + }); + describe('getValueExpression', () => { + it('should render multiple values in badges when operator type is match_any and values is Array', () => { + const wrapper = render( + getValueExpression(ListOperatorTypeEnum.MATCH_ANY, 'included', ['value 1', 'value 2']) + ); + expect(wrapper.getByTestId('matchAnyBadge0')).toHaveTextContent('value 1'); + expect(wrapper.getByTestId('matchAnyBadge1')).toHaveTextContent('value 2'); + }); + it('should return one value when operator type is match_any and values is not Array', () => { + const wrapper = render( + getValueExpression(ListOperatorTypeEnum.MATCH_ANY, 'included', 'value 1') + ); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent('value 1'); + }); + it('should return one value when operator type is a single value', () => { + const wrapper = render( + getValueExpression(ListOperatorTypeEnum.EXISTS, 'included', 'value 1') + ); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent('value 1'); + }); + it('should return value with warning icon when the value contains a leading or trailing space', () => { + const wrapper = render( + getValueExpression(ListOperatorTypeEnum.EXISTS, 'included', ' value 1') + ); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent(' value 1'); + expect(wrapper.getByTestId('valueWithSpaceWarningTooltip')).toBeInTheDocument(); + }); + it('should return value without warning icon when the value does not contain a leading or trailing space', () => { + const wrapper = render( + getValueExpression(ListOperatorTypeEnum.EXISTS, 'included', 'value 1') + ); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent(' value 1'); + expect(wrapper.queryByTestId('valueWithSpaceWarningTooltip')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.tsx index 6a64bcc810c0..e8d02dace3a8 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.tsx @@ -15,7 +15,11 @@ import type { Entry } from '../types'; const getEntryValue = (type: string, value?: string | string[]) => { if (type === 'match_any' && Array.isArray(value)) { - return value.map((currentValue) => {currentValue}); + return value.map((currentValue, index) => ( + + {currentValue} + + )); } return value ?? ''; }; @@ -42,6 +46,7 @@ export const getValueExpression = ( diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.test.tsx new file mode 100644 index 000000000000..7e02e19d6184 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { includedListTypeEntry } from '../../../mocks/entry.mock'; +import * as i18n from '../../translations'; +import { EntryContent } from '.'; + +describe('EntryContent', () => { + it('should render a single value without AND when index is 0', () => { + const wrapper = render( + + ); + expect(wrapper.getByTestId('EntryContentSingleEntry')).toBeInTheDocument(); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent('list_id'); + }); + it('should render a single value with AND when index is 1', () => { + const wrapper = render( + + ); + expect(wrapper.getByTestId('EntryContentSingleEntry')).toBeInTheDocument(); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent('list_id'); + expect(wrapper.getByText(i18n.CONDITION_AND)).toBeInTheDocument(); + }); + it('should render a nested value', () => { + const wrapper = render( + + ); + expect(wrapper.getByTestId('EntryContentNestedEntry')).toBeInTheDocument(); + expect(wrapper.getByTestId('nstedEntryIcon')).toBeInTheDocument(); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent('list_id'); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/index.tsx similarity index 79% rename from packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/index.tsx index 6c321a6d0ce0..8a5fe0b998fa 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/index.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { memo } from 'react'; +import React, { FC, memo } from 'react'; import { EuiExpression, EuiToken, EuiFlexGroup } from '@elastic/eui'; import { ListOperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { @@ -18,18 +18,15 @@ import type { Entry } from '../types'; import * as i18n from '../../translations'; import { getValue, getValueExpression } from './entry_content.helper'; -export const EntryContent = memo( - ({ - entry, - index, - isNestedEntry = false, - dataTestSubj, - }: { - entry: Entry; - index: number; - isNestedEntry?: boolean; - dataTestSubj?: string; - }) => { +interface EntryContentProps { + entry: Entry; + index: number; + isNestedEntry?: boolean; + dataTestSubj?: string; +} + +export const EntryContent: FC = memo( + ({ entry, index, isNestedEntry = false, dataTestSubj }) => { const { field, type } = entry; const value = getValue(entry); const operator = 'operator' in entry ? entry.operator : ''; @@ -40,12 +37,14 @@ export const EntryContent = memo(
{isNestedEntry ? ( - +
@@ -58,6 +57,7 @@ export const EntryContent = memo( description={index === 0 ? '' : i18n.CONDITION_AND} value={field} color={index === 0 ? 'primary' : 'subdued'} + data-test-subj={`${dataTestSubj || ''}SingleEntry`} /> {getValueExpression(type as ListOperatorTypeEnum, operator, value)} diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/index.tsx similarity index 94% rename from packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/index.tsx index 8b85a7343afc..28d9b1d9b09d 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/index.tsx @@ -10,8 +10,8 @@ import React, { memo } from 'react'; import { EuiPanel } from '@elastic/eui'; import { borderCss } from './conditions.styles'; -import { EntryContent } from './entry_content/entry_content'; -import { OsCondition } from './os_conditions/os_conditions'; +import { EntryContent } from './entry_content'; +import { OsCondition } from './os_conditions'; import type { CriteriaConditionsProps, Entry } from './types'; export const ExceptionItemCardConditions = memo( diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/__snapshots__/os_conditions.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/__snapshots__/os_conditions.test.tsx.snap new file mode 100644 index 000000000000..1ace7211d5e5 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/__snapshots__/os_conditions.test.tsx.snap @@ -0,0 +1,467 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OsCondition should render one OS_LABELS 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+ + + + + + OS + + + + + IS + + + + Mac + + + +
+
+ , + "container":
+
+ + + + + + OS + + + + + IS + + + + Mac + + + +
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`OsCondition should render two OS_LABELS 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+ + + + + + OS + + + + + IS + + + + Mac, Windows + + + +
+
+ , + "container":
+
+ + + + + + OS + + + + + IS + + + + Mac, Windows + + + +
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`OsCondition should return any os sent 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+ + + + + + OS + + + + + IS + + + + MacPro + + + +
+
+ , + "container":
+
+ + + + + + OS + + + + + IS + + + + MacPro + + + +
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`OsCondition should return empty body 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/index.tsx similarity index 79% rename from packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/index.tsx index 701529ae6717..ccd829d045bc 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/index.tsx @@ -14,7 +14,7 @@ import { OS_LABELS } from '../conditions.config'; import * as i18n from '../../translations'; export interface OsConditionsProps { - dataTestSubj: string; + dataTestSubj?: string; os: ExceptionListItemSchema['os_types']; } @@ -25,8 +25,12 @@ export const OsCondition = memo(({ os, dataTestSubj }) => { return osLabel ? (
- - + +
) : null; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.test.tsx new file mode 100644 index 000000000000..99a6f2ce0eca --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import * as i18n from '../../translations'; +import { OS_LABELS } from '../conditions.config'; +import { OsCondition } from '.'; + +describe('OsCondition', () => { + it('should render one OS_LABELS', () => { + const wrapper = render(); + expect(wrapper.getByTestId('osLabel')).toHaveTextContent(i18n.CONDITION_OS); + expect(wrapper.getByTestId('osValue')).toHaveTextContent( + `${i18n.CONDITION_OPERATOR_TYPE_MATCH} ${OS_LABELS.macos}` + ); + expect(wrapper).toMatchSnapshot(); + }); + it('should render two OS_LABELS', () => { + const wrapper = render(); + expect(wrapper.getByTestId('osLabel')).toHaveTextContent(i18n.CONDITION_OS); + expect(wrapper.getByTestId('osValue')).toHaveTextContent( + `${i18n.CONDITION_OPERATOR_TYPE_MATCH} ${OS_LABELS.macos}, ${OS_LABELS.windows}` + ); + expect(wrapper).toMatchSnapshot(); + }); + it('should return empty body', () => { + const wrapper = render(); + expect(wrapper).toMatchSnapshot(); + }); + it('should return any os sent', () => { + const wrapper = render(); + expect(wrapper.getByTestId('osLabel')).toHaveTextContent(i18n.CONDITION_OS); + expect(wrapper.getByTestId('osValue')).toHaveTextContent( + `${i18n.CONDITION_OPERATOR_TYPE_MATCH} MacPro` + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.test.tsx similarity index 65% rename from packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.test.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.test.tsx index 87a8a3bd3b52..649748f59303 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.test.tsx @@ -10,31 +10,11 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; import { ExceptionItemCard } from '.'; -import { getExceptionListItemSchemaMock } from '../test_helpers/exception_list_item_schema.mock'; -import { getCommentsArrayMock } from '../test_helpers/comments.mock'; +import { getExceptionListItemSchemaMock } from '../mocks/exception_list_item_schema.mock'; +import { getCommentsArrayMock, mockGetFormattedComments } from '../mocks/comments.mock'; import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { rules } from '../mocks/rule_references.mock'; -const ruleReferences: unknown[] = [ - { - exception_lists: [ - { - id: '123', - list_id: 'i_exist', - namespace_type: 'single', - type: 'detection', - }, - { - id: '456', - list_id: 'i_exist_2', - namespace_type: 'single', - type: 'detection', - }, - ], - id: '1a2b3c', - name: 'Simple Rule Query', - rule_id: 'rule-2', - }, -]; describe('ExceptionItemCard', () => { it('it renders header, item meta information and conditions', () => { const exceptionItem = { ...getExceptionListItemSchemaMock(), comments: [] }; @@ -43,7 +23,7 @@ describe('ExceptionItemCard', () => { { ); expect(wrapper.getByTestId('exceptionItemCardHeaderContainer')).toBeInTheDocument(); - // expect(wrapper.getByTestId('exceptionItemCardMetaInfo')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionItemCardMetaInfo')).toBeInTheDocument(); expect(wrapper.getByTestId('exceptionItemCardConditions')).toBeInTheDocument(); - // expect(wrapper.queryByTestId('exceptionsViewerCommentAccordion')).not.toBeInTheDocument(); + expect(wrapper.queryByTestId('exceptionsViewerCommentAccordion')).not.toBeInTheDocument(); }); - it('it renders header, item meta information, conditions, and comments if any exist', () => { + it('it should render the header, item meta information, conditions, and the comments', () => { const exceptionItem = { ...getExceptionListItemSchemaMock(), comments: getCommentsArrayMock() }; const wrapper = render( @@ -67,22 +47,22 @@ describe('ExceptionItemCard', () => { exceptionItem={exceptionItem} dataTestSubj="item" listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={ruleReferences} + ruleReferences={rules} onDeleteException={jest.fn()} onEditException={jest.fn()} securityLinkAnchorComponent={() => null} formattedDateComponent={() => null} - getFormattedComments={() => []} + getFormattedComments={mockGetFormattedComments} /> ); expect(wrapper.getByTestId('exceptionItemCardHeaderContainer')).toBeInTheDocument(); - // expect(wrapper.getByTestId('exceptionItemCardMetaInfo')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionItemCardMetaInfo')).toBeInTheDocument(); expect(wrapper.getByTestId('exceptionItemCardConditions')).toBeInTheDocument(); - // expect(wrapper.getByTestId('exceptionsViewerCommentAccordion')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionsItemCommentAccordion')).toBeInTheDocument(); }); - it('it does not render edit or delete action buttons when "disableActions" is "true"', () => { + it('it should not render edit or delete action buttons when "disableActions" is "true"', () => { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = render( @@ -93,7 +73,7 @@ describe('ExceptionItemCard', () => { exceptionItem={exceptionItem} dataTestSubj="item" listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={ruleReferences} + ruleReferences={rules} securityLinkAnchorComponent={() => null} formattedDateComponent={() => null} getFormattedComments={() => []} @@ -102,7 +82,7 @@ describe('ExceptionItemCard', () => { expect(wrapper.queryByTestId('itemActionButton')).not.toBeInTheDocument(); }); - it('it invokes "onEditException" when edit button clicked', () => { + it('it should invoke the "onEditException" when edit button clicked', () => { const mockOnEditException = jest.fn(); const exceptionItem = getExceptionListItemSchemaMock(); @@ -111,7 +91,7 @@ describe('ExceptionItemCard', () => { exceptionItem={exceptionItem} dataTestSubj="exceptionItemCardHeader" listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={ruleReferences} + ruleReferences={rules} onDeleteException={jest.fn()} onEditException={mockOnEditException} securityLinkAnchorComponent={() => null} @@ -120,12 +100,12 @@ describe('ExceptionItemCard', () => { /> ); - fireEvent.click(wrapper.getByTestId('exceptionItemCardHeaderActionButton')); + fireEvent.click(wrapper.getByTestId('exceptionItemCardHeaderButtonIcon')); fireEvent.click(wrapper.getByTestId('exceptionItemCardHeaderActionItemedit')); expect(mockOnEditException).toHaveBeenCalledWith(getExceptionListItemSchemaMock()); }); - it('it invokes "onDeleteException" when delete button clicked', () => { + it('it should invoke the "onDeleteException" when delete button clicked', () => { const mockOnDeleteException = jest.fn(); const exceptionItem = getExceptionListItemSchemaMock(); @@ -134,7 +114,7 @@ describe('ExceptionItemCard', () => { exceptionItem={exceptionItem} dataTestSubj="exceptionItemCardHeader" listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={ruleReferences} + ruleReferences={rules} onEditException={jest.fn()} onDeleteException={mockOnDeleteException} securityLinkAnchorComponent={() => null} @@ -142,7 +122,7 @@ describe('ExceptionItemCard', () => { getFormattedComments={() => []} /> ); - fireEvent.click(wrapper.getByTestId('exceptionItemCardHeaderActionButton')); + fireEvent.click(wrapper.getByTestId('exceptionItemCardHeaderButtonIcon')); fireEvent.click(wrapper.getByTestId('exceptionItemCardHeaderActionItemdelete')); expect(mockOnDeleteException).toHaveBeenCalledWith({ @@ -152,21 +132,25 @@ describe('ExceptionItemCard', () => { }); }); - // TODO Fix this Test - // it('it renders comment accordion closed to begin with', () => { - // const exceptionItem = getExceptionListItemSchemaMock(); - // exceptionItem.comments = getCommentsArrayMock(); - // const wrapper = render( - // - // ); - - // expect(wrapper.queryByTestId('accordion-comment-list')).not.toBeVisible(); - // }); + it('it should render comment accordion closed to begin with', () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.comments = getCommentsArrayMock(); + const wrapper = render( + null} + formattedDateComponent={() => null} + getFormattedComments={mockGetFormattedComments} + /> + ); + + expect(wrapper.getByTestId('exceptionsItemCommentAccordion')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionItemCardComments')).toBeVisible(); + expect(wrapper.getByTestId('accordionContentPanel')).not.toBeVisible(); + }); }); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.tsx index c3705750d015..a9aa5c7dedd8 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useMemo, useCallback, FC } from 'react'; +import React, { FC, ElementType } from 'react'; import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiCommentProps } from '@elastic/eui'; import type { CommentsArray, @@ -14,7 +14,6 @@ import type { ExceptionListTypeEnum, } from '@kbn/securitysolution-io-ts-list-types'; -import * as i18n from './translations'; import { ExceptionItemCardHeader, ExceptionItemCardConditions, @@ -22,18 +21,19 @@ import { ExceptionItemCardComments, } from '.'; -import type { ExceptionListItemIdentifiers } from '../types'; +import type { ExceptionListItemIdentifiers, Rule } from '../types'; +import { useExceptionItemCard } from './use_exception_item_card'; export interface ExceptionItemProps { dataTestSubj?: string; disableActions?: boolean; exceptionItem: ExceptionListItemSchema; listType: ExceptionListTypeEnum; - ruleReferences: any[]; // rulereferences + ruleReferences: Rule[]; editActionLabel?: string; deleteActionLabel?: string; - securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common - formattedDateComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + securityLinkAnchorComponent: ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + formattedDateComponent: ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common getFormattedComments: (comments: CommentsArray) => EuiCommentProps[]; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common onDeleteException: (arg: ExceptionListItemIdentifiers) => void; onEditException: (item: ExceptionListItemSchema) => void; @@ -53,47 +53,24 @@ const ExceptionItemCardComponent: FC = ({ onDeleteException, onEditException, }) => { - const handleDelete = useCallback((): void => { - onDeleteException({ - id: exceptionItem.id, - name: exceptionItem.name, - namespaceType: exceptionItem.namespace_type, - }); - }, [onDeleteException, exceptionItem.id, exceptionItem.name, exceptionItem.namespace_type]); - - const handleEdit = useCallback((): void => { - onEditException(exceptionItem); - }, [onEditException, exceptionItem]); - - const formattedComments = useMemo((): EuiCommentProps[] => { - return getFormattedComments(exceptionItem.comments); - }, [exceptionItem.comments, getFormattedComments]); - - const actions: Array<{ - key: string; - icon: string; - label: string | boolean; - onClick: () => void; - }> = useMemo( - () => [ - { - key: 'edit', - icon: 'controlsHorizontal', - label: editActionLabel || i18n.exceptionItemCardEditButton(listType), - onClick: handleEdit, - }, - { - key: 'delete', - icon: 'trash', - label: deleteActionLabel || listType === i18n.exceptionItemCardDeleteButton(listType), - onClick: handleDelete, - }, - ], - [editActionLabel, listType, deleteActionLabel, handleDelete, handleEdit] - ); + const { actions, formattedComments } = useExceptionItemCard({ + listType, + editActionLabel, + deleteActionLabel, + exceptionItem, + getFormattedComments, + onEditException, + onDeleteException, + }); return ( - - + + = ({ = ({ {formattedComments.length > 0 && ( )} diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.test.tsx index 7f7f20dfc234..71bc57e98a27 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.test.tsx @@ -8,30 +8,13 @@ import React from 'react'; -import { getExceptionListItemSchemaMock } from '../../test_helpers/exception_list_item_schema.mock'; -import * as i18n from '../translations'; -import { ExceptionItemCardHeader } from './header'; +import { getExceptionListItemSchemaMock } from '../../mocks/exception_list_item_schema.mock'; +import { ExceptionItemCardHeader } from '.'; import { fireEvent, render } from '@testing-library/react'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { actions, handleDelete, handleEdit } from '../../mocks/header.mock'; -const handleEdit = jest.fn(); -const handleDelete = jest.fn(); -const actions = [ - { - key: 'edit', - icon: 'pencil', - label: i18n.exceptionItemCardEditButton(ExceptionListTypeEnum.DETECTION), - onClick: handleEdit, - }, - { - key: 'delete', - icon: 'trash', - label: i18n.exceptionItemCardDeleteButton(ExceptionListTypeEnum.DETECTION), - onClick: handleDelete, - }, -]; describe('ExceptionItemCardHeader', () => { - it('it renders item name', () => { + it('it should render item name', () => { const wrapper = render( { expect(wrapper.getByTestId('exceptionItemHeaderTitle')).toHaveTextContent('some name'); }); - it('it displays actions', () => { + it('it should display actions', () => { const wrapper = render( { /> ); - // click on popover - fireEvent.click(wrapper.getByTestId('exceptionItemHeaderActionButton')); + fireEvent.click(wrapper.getByTestId('exceptionItemHeaderButtonIcon')); fireEvent.click(wrapper.getByTestId('exceptionItemHeaderActionItemedit')); expect(handleEdit).toHaveBeenCalled(); @@ -61,7 +43,7 @@ describe('ExceptionItemCardHeader', () => { expect(handleDelete).toHaveBeenCalled(); }); - it('it disables actions if disableActions is true', () => { + it('it should disable actions if disableActions is true', () => { const wrapper = render( { /> ); - expect(wrapper.getByTestId('exceptionItemHeaderActionButton')).toBeDisabled(); + expect(wrapper.getByTestId('exceptionItemHeaderButtonIcon')).toBeDisabled(); }); }); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.tsx deleted file mode 100644 index d58cb8d99b7a..000000000000 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { memo, useMemo, useState } from 'react'; -import type { EuiContextMenuPanelProps } from '@elastic/eui'; -import { - EuiButtonIcon, - EuiContextMenuPanel, - EuiFlexGroup, - EuiFlexItem, - EuiPopover, - EuiTitle, - EuiContextMenuItem, -} from '@elastic/eui'; -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; - -export interface ExceptionItemCardHeaderProps { - item: ExceptionListItemSchema; - actions: Array<{ key: string; icon: string; label: string | boolean; onClick: () => void }>; - disableActions?: boolean; - dataTestSubj: string; -} - -export const ExceptionItemCardHeader = memo( - ({ item, actions, disableActions = false, dataTestSubj }) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const onItemActionsClick = () => setIsPopoverOpen((isOpen) => !isOpen); - const onClosePopover = () => setIsPopoverOpen(false); - - const itemActions = useMemo((): EuiContextMenuPanelProps['items'] => { - return actions.map((action) => ( - { - onClosePopover(); - action.onClick(); - }} - > - {action.label} - - )); - }, [dataTestSubj, actions]); - - return ( - - - -

{item.name}

-
-
- - - } - panelPaddingSize="none" - isOpen={isPopoverOpen} - closePopover={onClosePopover} - data-test-subj={`${dataTestSubj}Items`} - > - - - -
- ); - } -); - -ExceptionItemCardHeader.displayName = 'ExceptionItemCardHeader'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/index.tsx new file mode 100644 index 000000000000..27b53db53673 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/index.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { HeaderMenu } from '../../header_menu'; + +export interface ExceptionItemCardHeaderProps { + item: ExceptionListItemSchema; + actions: Array<{ key: string; icon: string; label: string | boolean; onClick: () => void }>; + disableActions?: boolean; + dataTestSubj: string; +} + +export const ExceptionItemCardHeader = memo( + ({ item, actions, disableActions = false, dataTestSubj }) => { + return ( + + + +

{item.name}

+
+
+ + + +
+ ); + } +); + +ExceptionItemCardHeader.displayName = 'ExceptionItemCardHeader'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.ts b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.ts index c0fd3fafc86d..37a4d0456197 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.ts +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -export * from './conditions/conditions'; -export * from './header/header'; -export * from './meta/meta'; -export * from './comments/comments'; +export * from './conditions'; +export * from './header'; +export * from './meta'; +export * from './comments'; export * from './exception_item_card'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/__snapshots__/details_info.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/__snapshots__/details_info.test.tsx.snap new file mode 100644 index 000000000000..0575fac4f345 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/__snapshots__/details_info.test.tsx.snap @@ -0,0 +1,411 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MetaInfoDetails should render lastUpdate as JSX Element 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ created_by +
+
+
+ + + +

+ Last update value +

+
+
+
+
+
+
+ by +
+
+
+
+
+ + + + value + + + +
+
+
+
+
+ , + "container":
+
+
+
+ created_by +
+
+
+ + + +

+ Last update value +

+
+
+
+
+
+
+ by +
+
+
+
+
+ + + + value + + + +
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`MetaInfoDetails should render lastUpdate as string 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ created_by +
+
+
+ + + + last update + + + +
+
+
+ by +
+
+
+
+
+ + + + value + + + +
+
+
+
+
+ , + "container":
+
+
+
+ created_by +
+
+
+ + + + last update + + + +
+
+
+ by +
+
+
+
+
+ + + + value + + + +
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.test.tsx new file mode 100644 index 000000000000..7c994e0cce6b --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { MetaInfoDetails } from '.'; + +describe('MetaInfoDetails', () => { + it('should render lastUpdate as string', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('MetaInfoDetailslastUpdate')).toHaveTextContent('last update'); + }); + it('should render lastUpdate as JSX Element', () => { + const wrapper = render( + Last update value

} + lastUpdateValue="value" + /> + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('MetaInfoDetailslastUpdate')).toHaveTextContent('Last update value'); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/index.tsx similarity index 85% rename from packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/index.tsx index 3d075f50096d..f18b5c0dd31d 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/index.tsx @@ -13,11 +13,10 @@ import { euiThemeVars } from '@kbn/ui-theme'; import * as i18n from '../../translations'; interface MetaInfoDetailsProps { - fieldName: string; label: string; lastUpdate: JSX.Element | string; lastUpdateValue: string; - dataTestSubj: string; + dataTestSubj?: string; } const euiBadgeFontFamily = css` @@ -26,13 +25,19 @@ const euiBadgeFontFamily = css` export const MetaInfoDetails = memo( ({ label, lastUpdate, lastUpdateValue, dataTestSubj }) => { return ( - + {label} - + {lastUpdate} @@ -42,8 +47,8 @@ export const MetaInfoDetails = memo( {i18n.EXCEPTION_ITEM_CARD_META_BY} - - + + {lastUpdateValue} diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/index.tsx new file mode 100644 index 000000000000..1d7d6a356838 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/index.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import * as i18n from '../translations'; +import type { Rule } from '../../types'; +import { MetaInfoDetails } from './details_info'; +import { HeaderMenu } from '../../header_menu'; +import { generateLinkedRulesMenuItems } from '../../generate_linked_rules_menu_item'; + +const itemCss = css` + border-right: 1px solid #d3dae6; + padding: ${euiThemeVars.euiSizeS} ${euiThemeVars.euiSizeM} ${euiThemeVars.euiSizeS} 0; +`; + +export interface ExceptionItemCardMetaInfoProps { + item: ExceptionListItemSchema; + rules: Rule[]; + dataTestSubj: string; + formattedDateComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common +} + +export const ExceptionItemCardMetaInfo = memo( + ({ item, rules, dataTestSubj, securityLinkAnchorComponent, formattedDateComponent }) => { + const FormattedDateComponent = formattedDateComponent; + + const referencedLinks = useMemo( + () => + generateLinkedRulesMenuItems({ + dataTestSubj, + linkedRules: rules, + securityLinkAnchorComponent, + }), + [dataTestSubj, rules, securityLinkAnchorComponent] + ); + return ( + + {FormattedDateComponent !== null && ( + <> + + + } + lastUpdateValue={item.created_by} + dataTestSubj={`${dataTestSubj || ''}CreatedBy`} + /> + + + + + } + lastUpdateValue={item.updated_by} + dataTestSubj={`${dataTestSubj || ''}UpdatedBy`} + /> + + + )} + + + + + ); + } +); +ExceptionItemCardMetaInfo.displayName = 'ExceptionItemCardMetaInfo'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.test.tsx index c5ad9bd7af31..3a42760afaba 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.test.tsx @@ -8,38 +8,17 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { getExceptionListItemSchemaMock } from '../../test_helpers/exception_list_item_schema.mock'; +import { getExceptionListItemSchemaMock } from '../../mocks/exception_list_item_schema.mock'; -import { ExceptionItemCardMetaInfo } from './meta'; -import { RuleReference } from '../../types'; +import { ExceptionItemCardMetaInfo } from '.'; +import { rules } from '../../mocks/rule_references.mock'; -const ruleReferences = [ - { - exception_lists: [ - { - id: '123', - list_id: 'i_exist', - namespace_type: 'single', - type: 'detection', - }, - { - id: '456', - list_id: 'i_exist_2', - namespace_type: 'single', - type: 'detection', - }, - ], - id: '1a2b3c', - name: 'Simple Rule Query', - rule_id: 'rule-2', - }, -]; describe('ExceptionItemCardMetaInfo', () => { it('it should render creation info with sending custom formattedDateComponent', () => { const wrapper = render( null} formattedDateComponent={({ fieldName, value }) => ( @@ -62,7 +41,7 @@ describe('ExceptionItemCardMetaInfo', () => { const wrapper = render( null} formattedDateComponent={({ fieldName, value }) => ( @@ -84,71 +63,67 @@ describe('ExceptionItemCardMetaInfo', () => { const wrapper = render( null} formattedDateComponent={() => null} /> ); - expect(wrapper.getByTestId('exceptionItemMetaAffectedRulesButton')).toHaveTextContent( - 'Affects 1 rule' - ); + expect(wrapper.getByTestId('exceptionItemMetaEmptyButton')).toHaveTextContent('Affects 1 rule'); }); - it('it renders references info when multiple references exist', () => { + it('it should render references info when multiple references exist', () => { const wrapper = render( null} formattedDateComponent={() => null} /> ); - expect(wrapper.getByTestId('exceptionItemMetaAffectedRulesButton')).toHaveTextContent( + expect(wrapper.getByTestId('exceptionItemMetaEmptyButton')).toHaveTextContent( 'Affects 2 rules' ); }); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.tsx deleted file mode 100644 index 91e0a9cdd19b..000000000000 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { memo, useMemo, useState } from 'react'; -import type { EuiContextMenuPanelProps } from '@elastic/eui'; -import { - EuiContextMenuItem, - EuiContextMenuPanel, - EuiFlexGroup, - EuiFlexItem, - EuiToolTip, - EuiButtonEmpty, - EuiPopover, -} from '@elastic/eui'; -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; - -import { css } from '@emotion/react'; -import * as i18n from '../translations'; -import type { RuleReference } from '../../types'; -import { MetaInfoDetails } from './details_info/details_info'; - -const itemCss = css` - border-right: 1px solid #d3dae6; - padding: 4px 12px 4px 0; -`; - -export interface ExceptionItemCardMetaInfoProps { - item: ExceptionListItemSchema; - references: RuleReference[]; - dataTestSubj: string; - formattedDateComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common - securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common -} - -export const ExceptionItemCardMetaInfo = memo( - ({ item, references, dataTestSubj, securityLinkAnchorComponent, formattedDateComponent }) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const onAffectedRulesClick = () => setIsPopoverOpen((isOpen) => !isOpen); - const onClosePopover = () => setIsPopoverOpen(false); - - const FormattedDateComponent = formattedDateComponent; - const itemActions = useMemo((): EuiContextMenuPanelProps['items'] => { - if (references == null || securityLinkAnchorComponent === null) { - return []; - } - - const SecurityLinkAnchor = securityLinkAnchorComponent; - return references.map((reference) => ( - - - - - - )); - }, [references, securityLinkAnchorComponent, dataTestSubj]); - - return ( - - {FormattedDateComponent !== null && ( - <> - - - } - lastUpdateValue={item.created_by} - dataTestSubj={`${dataTestSubj}CreatedBy`} - /> - - - - - } - lastUpdateValue={item.updated_by} - dataTestSubj={`${dataTestSubj}UpdatedBy`} - /> - - - )} - - - {i18n.AFFECTED_RULES(references.length)} - - } - panelPaddingSize="none" - isOpen={isPopoverOpen} - closePopover={onClosePopover} - data-test-subj={`${dataTestSubj}Items`} - > - - - - - ); - } -); -ExceptionItemCardMetaInfo.displayName = 'ExceptionItemCardMetaInfo'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.test.ts b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.test.ts new file mode 100644 index 000000000000..b8bbfaa70b61 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { getExceptionListItemSchemaMock } from '../mocks/exception_list_item_schema.mock'; +import { useExceptionItemCard } from './use_exception_item_card'; +import * as i18n from './translations'; +import { mockGetFormattedComments } from '../mocks/comments.mock'; + +const onEditException = jest.fn(); +const onDeleteException = jest.fn(); +const getFormattedComments = jest.fn(); +const exceptionItem = getExceptionListItemSchemaMock(); +describe('useExceptionItemCard', () => { + it('should call onEditException with the correct params', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionItemCard({ + listType: ExceptionListTypeEnum.DETECTION, + exceptionItem, + onEditException, + onDeleteException, + getFormattedComments, + }) + ); + const { actions } = current; + + act(() => { + actions[0].onClick(); + }); + expect(onEditException).toHaveBeenCalledWith(exceptionItem); + }); + it('should call onDeleteException with the correct params', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionItemCard({ + listType: ExceptionListTypeEnum.DETECTION, + exceptionItem, + onEditException, + onDeleteException, + getFormattedComments, + }) + ); + const { actions } = current; + + act(() => { + actions[1].onClick(); + }); + expect(onDeleteException).toHaveBeenCalledWith({ + id: exceptionItem.id, + name: exceptionItem.name, + namespaceType: exceptionItem.namespace_type, + }); + }); + it('should return the default actions labels', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionItemCard({ + listType: ExceptionListTypeEnum.DETECTION, + exceptionItem, + onEditException, + onDeleteException, + getFormattedComments, + }) + ); + const { actions } = current; + const [editAction, deleteAction] = actions; + + expect(editAction.label).toEqual( + i18n.exceptionItemCardEditButton(ExceptionListTypeEnum.DETECTION) + ); + expect(deleteAction.label).toEqual( + i18n.exceptionItemCardDeleteButton(ExceptionListTypeEnum.DETECTION) + ); + }); + it('should return the default sent labels props', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionItemCard({ + listType: ExceptionListTypeEnum.DETECTION, + exceptionItem, + editActionLabel: 'Edit', + deleteActionLabel: 'Delete', + onEditException, + onDeleteException, + getFormattedComments, + }) + ); + const { actions } = current; + const [editAction, deleteAction] = actions; + + expect(editAction.label).toEqual('Edit'); + expect(deleteAction.label).toEqual('Delete'); + }); + it('should return formattedComments', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionItemCard({ + listType: ExceptionListTypeEnum.DETECTION, + exceptionItem, + editActionLabel: 'Edit', + deleteActionLabel: 'Delete', + onEditException, + onDeleteException, + getFormattedComments: mockGetFormattedComments, + }) + ); + const { formattedComments } = current; + expect(formattedComments[0].username).toEqual('some user'); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.ts b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.ts new file mode 100644 index 000000000000..dda7ae7d7aa0 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useCallback, useMemo } from 'react'; +import { EuiCommentProps } from '@elastic/eui'; + +import { + CommentsArray, + ExceptionListItemSchema, + ExceptionListTypeEnum, +} from '@kbn/securitysolution-io-ts-list-types'; +import * as i18n from './translations'; +import { ExceptionListItemIdentifiers } from '../types'; + +interface UseExceptionItemCardProps { + exceptionItem: ExceptionListItemSchema; + listType: ExceptionListTypeEnum; + editActionLabel?: string; + deleteActionLabel?: string; + getFormattedComments: (comments: CommentsArray) => EuiCommentProps[]; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + onDeleteException: (arg: ExceptionListItemIdentifiers) => void; + onEditException: (item: ExceptionListItemSchema) => void; +} + +export const useExceptionItemCard = ({ + listType, + editActionLabel, + deleteActionLabel, + exceptionItem, + getFormattedComments, + onEditException, + onDeleteException, +}: UseExceptionItemCardProps) => { + const handleDelete = useCallback((): void => { + onDeleteException({ + id: exceptionItem.id, + name: exceptionItem.name, + namespaceType: exceptionItem.namespace_type, + }); + }, [onDeleteException, exceptionItem.id, exceptionItem.name, exceptionItem.namespace_type]); + + const handleEdit = useCallback((): void => { + onEditException(exceptionItem); + }, [onEditException, exceptionItem]); + + const formattedComments = useMemo((): EuiCommentProps[] => { + return getFormattedComments(exceptionItem.comments); + }, [exceptionItem.comments, getFormattedComments]); + + const actions: Array<{ + key: string; + icon: string; + label: string | boolean; + onClick: () => void; + }> = useMemo( + () => [ + { + key: 'edit', + icon: 'controlsHorizontal', + label: editActionLabel || i18n.exceptionItemCardEditButton(listType), + onClick: handleEdit, + }, + { + key: 'delete', + icon: 'trash', + label: deleteActionLabel || i18n.exceptionItemCardDeleteButton(listType), + onClick: handleDelete, + }, + ], + [editActionLabel, listType, deleteActionLabel, handleDelete, handleEdit] + ); + return { + actions, + formattedComments, + }; +}; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.test.tsx index 39c429dd1f1d..da5df5299695 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.test.tsx @@ -8,13 +8,17 @@ import React from 'react'; -import { getExceptionListItemSchemaMock } from '../test_helpers/exception_list_item_schema.mock'; +import { getExceptionListItemSchemaMock } from '../mocks/exception_list_item_schema.mock'; import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { ExceptionItems } from './exception_items'; +import { ExceptionItems } from '.'; import { ViewerStatus } from '../types'; -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; +import { ruleReferences } from '../mocks/rule_references.mock'; +import { Pagination } from '@elastic/eui'; +import { mockGetFormattedComments } from '../mocks/comments.mock'; +import { securityLinkAnchorComponentMock } from '../mocks/security_link_component.mock'; const onCreateExceptionListItem = jest.fn(); const onDeleteException = jest.fn(); @@ -25,7 +29,7 @@ const pagination = { pageIndex: 0, pageSize: 0, totalItemCount: 0 }; describe('ExceptionsViewerItems', () => { describe('Viewing EmptyViewerState', () => { - it('it renders empty prompt if "viewerStatus" is "empty"', () => { + it('it should render empty prompt if "viewerStatus" is "empty"', () => { const wrapper = render( { getFormattedComments={() => []} /> ); - // expect(wrapper).toMatchSnapshot(); expect(wrapper.getByTestId('emptyViewerState')).toBeInTheDocument(); expect(wrapper.queryByTestId('exceptionsContainer')).not.toBeInTheDocument(); }); - it('it renders no search results found prompt if "viewerStatus" is "empty_search"', () => { + it('it should render no search results found prompt if "viewerStatus" is "empty_search"', () => { const wrapper = render( { getFormattedComments={() => []} /> ); - // expect(wrapper).toMatchSnapshot(); expect(wrapper.getByTestId('emptySearchViewerState')).toBeInTheDocument(); expect(wrapper.queryByTestId('exceptionsContainer')).not.toBeInTheDocument(); }); - - it('it renders exceptions if "viewerStatus" and "null"', () => { + }); + describe('Exception Items and Pagination', () => { + it('it should render exceptions if exception array is not empty', () => { const wrapper = render( { getFormattedComments={() => []} /> ); - // expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('exceptionsContainer')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionItemCard')).toBeInTheDocument(); + expect(wrapper.getAllByTestId('exceptionItemCard')).toHaveLength(1); + }); + it('it should render pagination section', () => { + const exceptions = [ + getExceptionListItemSchemaMock(), + { ...getExceptionListItemSchemaMock(), id: '2' }, + ]; + const wrapper = render( + null} + formattedDateComponent={() => null} + exceptionsUtilityComponent={() => null} + getFormattedComments={() => []} + /> + ); expect(wrapper.getByTestId('exceptionsContainer')).toBeTruthy(); + expect(wrapper.getAllByTestId('exceptionItemCard')).toHaveLength(2); + expect(wrapper.getByTestId('pagination')).toBeInTheDocument(); + }); + }); + + describe('securityLinkAnchorComponent, formattedDateComponent, exceptionsUtilityComponent and getFormattedComments', () => { + it('it should render sent securityLinkAnchorComponent', () => { + const wrapper = render( + null} + exceptionsUtilityComponent={() => null} + getFormattedComments={() => []} + /> + ); + expect(wrapper.getByTestId('exceptionsContainer')).toBeInTheDocument(); + fireEvent.click(wrapper.getByTestId('exceptionItemCardMetaInfoEmptyButton')); + expect(wrapper.getByTestId('securityLinkAnchorComponent')).toBeInTheDocument(); + }); + it('it should render sent exceptionsUtilityComponent', () => { + const exceptionsUtilityComponent = ({ + pagination: utilityPagination, + lastUpdated, + }: { + pagination: Pagination; + lastUpdated: string; + }) => ( +
+ {lastUpdated} + {utilityPagination.pageIndex} +
+ ); + const wrapper = render( + null} + formattedDateComponent={() => null} + exceptionsUtilityComponent={exceptionsUtilityComponent} + getFormattedComments={() => []} + /> + ); + expect(wrapper.getByTestId('exceptionsContainer')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionsUtilityComponent')).toBeInTheDocument(); + expect(wrapper.getByTestId('lastUpdateTestUtility')).toHaveTextContent('1666003695578'); + expect(wrapper.getByTestId('paginationTestUtility')).toHaveTextContent('0'); + }); + it('it should render sent formattedDateComponent', () => { + const formattedDateComponent = ({ + fieldName, + value, + }: { + fieldName: string; + value: string; + }) => ( +
+ {fieldName} + {value} +
+ ); + const wrapper = render( + null} + formattedDateComponent={formattedDateComponent} + exceptionsUtilityComponent={() => null} + getFormattedComments={() => []} + /> + ); + expect(wrapper.getByTestId('exceptionsContainer')).toBeInTheDocument(); + expect(wrapper.getAllByTestId('formattedDateComponent')).toHaveLength(2); + expect(wrapper.getAllByTestId('fieldNameTestFormatted')[0]).toHaveTextContent('created_at'); + expect(wrapper.getAllByTestId('fieldNameTestFormatted')[1]).toHaveTextContent('updated_at'); + expect(wrapper.getAllByTestId('valueTestFormatted')[0]).toHaveTextContent( + '2020-04-20T15:25:31.830Z' + ); + expect(wrapper.getAllByTestId('valueTestFormatted')[0]).toHaveTextContent( + '2020-04-20T15:25:31.830Z' + ); + }); + it('it should use getFormattedComments to extract comments', () => { + const wrapper = render( + null} + formattedDateComponent={() => null} + exceptionsUtilityComponent={() => null} + getFormattedComments={mockGetFormattedComments} + /> + ); + expect(wrapper.getByTestId('exceptionsContainer')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionsItemCommentAccordion')).toBeInTheDocument(); }); }); - // TODO Add Exception Items and Pagination interactions }); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_items/index.tsx similarity index 75% rename from packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_items/index.tsx index 80ab3d99f6eb..647ff3a14458 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_items/index.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { ElementType } from 'react'; import { css } from '@emotion/react'; import type { FC } from 'react'; import { EuiCommentProps, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; @@ -47,9 +47,12 @@ interface ExceptionItemsProps { listType: ExceptionListTypeEnum; ruleReferences: RuleReferences; pagination: PaginationType; - securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common - formattedDateComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common - exceptionsUtilityComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + editActionLabel?: string; + deleteActionLabel?: string; + dataTestSubj?: string; + securityLinkAnchorComponent: ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + formattedDateComponent: ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + exceptionsUtilityComponent: ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common getFormattedComments: (comments: CommentsArray) => EuiCommentProps[]; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common onCreateExceptionListItem?: () => void; onDeleteException: (arg: ExceptionListItemIdentifiers) => void; @@ -68,6 +71,9 @@ const ExceptionItemsComponent: FC = ({ emptyViewerBody, emptyViewerButtonText, pagination, + dataTestSubj, + editActionLabel, + deleteActionLabel, securityLinkAnchorComponent, exceptionsUtilityComponent, formattedDateComponent, @@ -96,24 +102,31 @@ const ExceptionItemsComponent: FC = ({ {exceptions.map((exception) => ( - + = ({ diff --git a/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/__snapshots__/generate_linked_rules_menu_item.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/__snapshots__/generate_linked_rules_menu_item.test.tsx.snap new file mode 100644 index 000000000000..7c2e71a7ffe9 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/__snapshots__/generate_linked_rules_menu_item.test.tsx.snap @@ -0,0 +1,267 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateLinedRulesMenuItems should render the first linked rules with left icon and does not apply the css if the length is 1 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ +
+ , + "container":
+ +
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`generateLinedRulesMenuItems should render the second linked rule and apply the css when the length is > 1 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ +
+ , + "container":
+ +
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/generate_linked_rules_menu_item.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/generate_linked_rules_menu_item.test.tsx new file mode 100644 index 000000000000..2158f05f6353 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/generate_linked_rules_menu_item.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { render } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { ElementType } from 'react'; +import { generateLinkedRulesMenuItems } from '.'; +import { rules } from '../mocks/rule_references.mock'; +import { + getSecurityLinkAction, + securityLinkAnchorComponentMock, +} from '../mocks/security_link_component.mock'; + +const dataTestSubj = 'generateLinedRulesMenuItemsTest'; +const linkedRules = rules; + +describe('generateLinedRulesMenuItems', () => { + it('should not render if the linkedRules length is falsy', () => { + const result = generateLinkedRulesMenuItems({ + dataTestSubj, + linkedRules: [], + securityLinkAnchorComponent: securityLinkAnchorComponentMock, + }); + expect(result).toBeNull(); + }); + it('should not render if the securityLinkAnchorComponent length is falsy', () => { + const result = generateLinkedRulesMenuItems({ + dataTestSubj, + linkedRules, + securityLinkAnchorComponent: null as unknown as ElementType, + }); + expect(result).toBeNull(); + }); + it('should render the first linked rules with left icon and does not apply the css if the length is 1', () => { + const result: ReactElement[] = generateLinkedRulesMenuItems({ + dataTestSubj, + linkedRules, + securityLinkAnchorComponent: securityLinkAnchorComponentMock, + leftIcon: 'check', + }) as ReactElement[]; + + result.map((link) => { + const wrapper = render(link); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('generateLinedRulesMenuItemsTestActionItem1a2b3c')); + expect(wrapper.getByTestId('generateLinedRulesMenuItemsTestLeftIcon')); + }); + }); + it('should render the second linked rule and apply the css when the length is > 1', () => { + const result: ReactElement[] = getSecurityLinkAction(dataTestSubj); + + const wrapper = render(result[1]); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('generateLinedRulesMenuItemsTestActionItem2a2b3c')); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/index.tsx new file mode 100644 index 000000000000..a78a76319cbb --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/index.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { ElementType, ReactElement } from 'react'; +import { EuiContextMenuItem, EuiFlexGroup, EuiFlexItem, EuiIcon, IconType } from '@elastic/eui'; +import { Rule } from '../types'; +import { itemContentCss, containerCss } from './menu_link.styles'; + +interface MenuItemLinkedRulesProps { + leftIcon?: IconType; + dataTestSubj?: string; + linkedRules: Rule[]; + securityLinkAnchorComponent: ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common +} + +export const generateLinkedRulesMenuItems = ({ + dataTestSubj, + linkedRules, + securityLinkAnchorComponent, + leftIcon = '', +}: MenuItemLinkedRulesProps): ReactElement[] | null => { + if (!linkedRules.length || securityLinkAnchorComponent === null) return null; + + const SecurityLinkAnchor = securityLinkAnchorComponent; + return linkedRules.map((rule) => { + return ( + 1 ? containerCss : ''} + data-test-subj={`${dataTestSubj || ''}ActionItem${rule.id}`} + key={rule.id} + > + + {leftIcon ? ( + + + + ) : null} + + + + + + ); + }); +}; diff --git a/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/menu_link.styles.ts b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/menu_link.styles.ts new file mode 100644 index 000000000000..0cfbcf522bc2 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/menu_link.styles.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; + +export const containerCss = css` + border-bottom: 1px solid ${euiThemeVars.euiColorLightShade}; +`; + +export const itemContentCss = css` + color: ${euiThemeVars.euiColorPrimary}; + flex-basis: content; +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/header_menu/__snapshots__/header_menu.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/header_menu/__snapshots__/header_menu.test.tsx.snap new file mode 100644 index 000000000000..dd808cfd1889 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/header_menu/__snapshots__/header_menu.test.tsx.snap @@ -0,0 +1,626 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HeaderMenu should render button icon with default settings 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`HeaderMenu should render custom Actions 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`HeaderMenu should render empty button icon with actions and open the popover when clicked 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`HeaderMenu should render empty button icon with actions and should not open the popover when clicked if disableActions 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`HeaderMenu should render empty button icon with different icon settings 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/header_menu/header_menu.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/header_menu/header_menu.test.tsx new file mode 100644 index 000000000000..b07079c721ff --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/header_menu/header_menu.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { HeaderMenu } from '.'; +import { actions } from '../mocks/header.mock'; +import { getSecurityLinkAction } from '../mocks/security_link_component.mock'; + +describe('HeaderMenu', () => { + it('should render button icon with default settings', () => { + const wrapper = render(); + + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.getByTestId('ButtonIcon')).toBeInTheDocument(); + expect(wrapper.queryByTestId('EmptyButton')).not.toBeInTheDocument(); + expect(wrapper.queryByTestId('MenuPanel')).not.toBeInTheDocument(); + }); + + it('should render empty button icon with different icon settings', () => { + const wrapper = render( + + ); + + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.getByTestId('EmptyButton')).toBeInTheDocument(); + expect(wrapper.queryByTestId('ButtonIcon')).not.toBeInTheDocument(); + expect(wrapper.queryByTestId('MenuPanel')).not.toBeInTheDocument(); + }); + + it('should render empty button icon with actions and open the popover when clicked', () => { + const wrapper = render( + + ); + + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.getByTestId('EmptyButton')).toBeInTheDocument(); + expect(wrapper.queryByTestId('ButtonIcon')).not.toBeInTheDocument(); + fireEvent.click(wrapper.getByTestId('EmptyButton')); + expect(wrapper.getByTestId('ActionItemedit')).toBeInTheDocument(); + expect(wrapper.getByTestId('MenuPanel')).toBeInTheDocument(); + }); + it('should render empty button icon with actions and should not open the popover when clicked if disableActions', () => { + const wrapper = render( + + ); + + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.getByTestId('EmptyButton')).toBeInTheDocument(); + expect(wrapper.queryByTestId('ButtonIcon')).not.toBeInTheDocument(); + fireEvent.click(wrapper.getByTestId('EmptyButton')); + expect(wrapper.queryByTestId('ActionItemedit')).not.toBeInTheDocument(); + expect(wrapper.queryByTestId('MenuPanel')).not.toBeInTheDocument(); + }); + + it('should call onEdit if action has onClick', () => { + const onEdit = jest.fn(); + const customAction = [...actions]; + customAction[0].onClick = onEdit; + const wrapper = render(); + fireEvent.click(wrapper.getByTestId('ButtonIcon')); + fireEvent.click(wrapper.getByTestId('ActionItemedit')); + expect(onEdit).toBeCalled(); + }); + + it('should render custom Actions', () => { + const customActions = getSecurityLinkAction('headerMenuTest'); + const wrapper = render( + + ); + + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.getByTestId('EmptyButton')).toBeInTheDocument(); + fireEvent.click(wrapper.getByTestId('EmptyButton')); + expect(wrapper.queryByTestId('MenuPanel')).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/header_menu/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/header_menu/index.tsx new file mode 100644 index 000000000000..43154a865a43 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/header_menu/index.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, ReactElement, useMemo, useState } from 'react'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiPopover, + IconType, + PanelPaddingSize, + PopoverAnchorPosition, +} from '@elastic/eui'; +import { ButtonContentIconSide } from '@elastic/eui/src/components/button/_button_content_deprecated'; + +interface Action { + key: string; + icon: string; + label: string | boolean; + onClick: () => void; +} +interface HeaderMenuComponentProps { + disableActions: boolean; + actions: Action[] | ReactElement[] | null; + text?: string; + iconType?: IconType; + iconSide?: ButtonContentIconSide; + dataTestSubj?: string; + emptyButton?: boolean; + useCustomActions?: boolean; + anchorPosition?: PopoverAnchorPosition; + panelPaddingSize?: PanelPaddingSize; +} + +const HeaderMenuComponent: FC = ({ + text, + dataTestSubj, + actions, + disableActions, + emptyButton, + useCustomActions, + iconType = 'boxesHorizontal', + iconSide = 'left', + anchorPosition = 'downCenter', + panelPaddingSize = 's', +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onAffectedRulesClick = () => setIsPopoverOpen((isOpen) => !isOpen); + const onClosePopover = () => setIsPopoverOpen(false); + + const itemActions = useMemo(() => { + if (useCustomActions || actions === null) return actions; + return (actions as Action[]).map((action) => ( + { + onClosePopover(); + if (typeof action.onClick === 'function') action.onClick(); + }} + > + {action.label} + + )); + }, [actions, dataTestSubj, useCustomActions]); + + return ( + + + {text} + + ) : ( + + {text} + + ) + } + panelPaddingSize={panelPaddingSize} + isOpen={isPopoverOpen} + closePopover={onClosePopover} + anchorPosition={anchorPosition} + data-test-subj={`${dataTestSubj || ''}Items`} + > + {!itemActions ? null : ( + + )} + + + ); +}; +HeaderMenuComponent.displayName = 'HeaderMenuComponent'; + +export const HeaderMenu = React.memo(HeaderMenuComponent); + +HeaderMenu.displayName = 'HeaderMenu'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/__snapshots__/list_header.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/list_header/__snapshots__/list_header.test.tsx.snap new file mode 100644 index 000000000000..98f026e4ce94 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/__snapshots__/list_header.test.tsx.snap @@ -0,0 +1,1711 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExceptionListHeader should render edit modal 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+

+
+ + List Name + + +
+

+
+
+

+

+
+ + List description + + +
+
+
+ List ID + : +
+
+ List_Id +
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

+ Edit List Name +

+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+

+
+ + List Name + + +
+

+
+
+

+

+
+ + List description + + +
+
+
+ List ID + : +
+
+ List_Id +
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ExceptionListHeader should render the List Header with name, default description and actions 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+

+
+ + List Name + + +
+

+
+
+

+

+
+ + Add a description + + +
+
+
+ List ID + : +
+
+ List_Id +
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+

+
+ + List Name + + +
+

+
+
+

+

+
+ + Add a description + + +
+
+
+ List ID + : +
+
+ List_Id +
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ExceptionListHeader should render the List Header with name, default description and disabled actions because of the ReadOnly mode 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+

+
+ + List Name + +
+

+
+
+

+

+
+ + Add a description + +
+
+
+ List ID + : +
+
+ List_Id +
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+

+
+ + List Name + +
+

+
+
+

+

+
+ + Add a description + +
+
+
+ List ID + : +
+
+ List_Id +
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/__snapshots__/edit_modal.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/__snapshots__/edit_modal.test.tsx.snap new file mode 100644 index 000000000000..8de3d7e099f4 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/__snapshots__/edit_modal.test.tsx.snap @@ -0,0 +1,227 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditModal should render the title and description from listDetails 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+

+ Edit list name +

+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/edit_modal.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/edit_modal.test.tsx new file mode 100644 index 000000000000..39786c1723b4 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/edit_modal.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { EditModal } from '.'; + +const onSave = jest.fn(); +const onCancel = jest.fn(); + +describe('EditModal', () => { + it('should render the title and description from listDetails', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('editModalTitle')).toHaveTextContent('list name'); + }); + it('should call onSave', () => { + const wrapper = render( + + ); + fireEvent.submit(wrapper.getByTestId('editModalForm')); + expect(onSave).toBeCalled(); + }); + it('should call onCancel', () => { + const wrapper = render( + + ); + fireEvent.click(wrapper.getByTestId('editModalCancelBtn')); + expect(onCancel).toBeCalled(); + }); + + it('should call change title, description and call onSave with the new props', () => { + const wrapper = render( + + ); + fireEvent.change(wrapper.getByTestId('editModalNameTextField'), { + target: { value: 'New list name' }, + }); + fireEvent.change(wrapper.getByTestId('editModalDescriptionTextField'), { + target: { value: 'New description name' }, + }); + fireEvent.submit(wrapper.getByTestId('editModalForm')); + + expect(onSave).toBeCalledWith({ + name: 'New list name', + description: 'New description name', + }); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/index.tsx new file mode 100644 index 000000000000..8ef417412843 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/index.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { ChangeEvent, FC, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; +import * as i18n from '../../translations'; +import { ListDetails } from '../../types'; + +interface EditModalProps { + listDetails: ListDetails; + onSave: (newListDetails: ListDetails) => void; + onCancel: () => void; +} + +const EditModalComponent: FC = ({ listDetails, onSave, onCancel }) => { + const modalFormId = useGeneratedHtmlId({ prefix: 'modalForm' }); + const [newListDetails, setNewListDetails] = useState(listDetails); + + const onChange = ({ target }: ChangeEvent) => { + const { name, value } = target; + setNewListDetails({ ...newListDetails, [name]: value }); + }; + const onSubmit = () => { + onSave(newListDetails); + }; + return ( + + + +

{i18n.EXCEPTION_LIST_HEADER_EDIT_MODAL_TITLE(listDetails.name)}

+
+
+ + + + + + + + + + + + + + + + {i18n.EXCEPTION_LIST_HEADER_EDIT_MODAL_CANCEL_BUTTON} + + + + {i18n.EXCEPTION_LIST_HEADER_EDIT_MODAL_SAVE_BUTTON} + + +
+ ); +}; +EditModalComponent.displayName = 'EditModalComponent'; + +export const EditModal = React.memo(EditModalComponent); + +EditModal.displayName = 'EditModal'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/index.tsx new file mode 100644 index 000000000000..570be26e2e84 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/index.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import type { FC } from 'react'; +import { EuiIcon, EuiPageHeader, EuiText } from '@elastic/eui'; +import * as i18n from '../translations'; +import { + textWithEditContainerCss, + textCss, + descriptionContainerCss, + headerCss, +} from './list_header.styles'; +import { MenuItems } from './menu_items'; +import { TextWithEdit } from '../text_with_edit'; +import { EditModal } from './edit_modal'; +import { ListDetails, Rule } from '../types'; +import { useExceptionListHeader } from './use_list_header'; + +interface ExceptionListHeaderComponentProps { + name: string; + description?: string; + listId: string; + isReadonly: boolean; + linkedRules: Rule[]; + dataTestSubj?: string; + breadcrumbLink?: string; + securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + onEditListDetails: (listDetails: ListDetails) => void; + onExportList: () => void; + onDeleteList: () => void; + onManageRules: () => void; +} + +const ExceptionListHeaderComponent: FC = ({ + name, + description, + listId, + linkedRules, + isReadonly, + dataTestSubj, + securityLinkAnchorComponent, + breadcrumbLink, + onEditListDetails, + onExportList, + onDeleteList, + onManageRules, +}) => { + const { isModalVisible, listDetails, onEdit, onSave, onCancel } = useExceptionListHeader({ + name, + description, + onEditListDetails, + }); + return ( +
+ + } + responsive + data-test-subj={`${dataTestSubj || ''}PageHeader`} + description={ +
+ +
+ {i18n.EXCEPTION_LIST_HEADER_LIST_ID}: + {listId} +
+
+ } + rightSideItems={[ + , + ]} + breadcrumbs={[ + { + text: ( +
+ + {i18n.EXCEPTION_LIST_HEADER_BREADCRUMB} +
+ ), + color: 'primary', + 'aria-current': false, + href: breadcrumbLink, + onClick: (e) => e.preventDefault(), + }, + ]} + /> + {isModalVisible && ( + + )} +
+ ); +}; + +ExceptionListHeaderComponent.displayName = 'ExceptionListHeaderComponent'; + +export const ExceptionListHeader = React.memo(ExceptionListHeaderComponent); + +ExceptionListHeader.displayName = 'ExceptionListHeader'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.styles.ts b/packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.styles.ts new file mode 100644 index 000000000000..ab9d6a5c79d5 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.styles.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; + +export const headerCss = css` + margin: ${euiThemeVars.euiSize}; +`; + +export const headerMenuCss = css` + border-right: 1px solid #d3dae6; + padding: ${euiThemeVars.euiSizeXS} ${euiThemeVars.euiSizeL} ${euiThemeVars.euiSizeXS} 0; +`; +export const textWithEditContainerCss = css` + display: flex; + width: fit-content; + align-items: baseline; + margin-bottom: ${euiThemeVars.euiSizeS}; + h1 { + margin-bottom: 0; + } +`; +export const textCss = css` + font-size: ${euiThemeVars.euiFontSize}; + color: ${euiThemeVars.euiTextSubduedColor}; + margin-left: ${euiThemeVars.euiSizeXS}; +`; +export const descriptionContainerCss = css` + margin-top: -${euiThemeVars.euiSizeL}; + margin-bottom: -${euiThemeVars.euiSizeL}; +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.test.tsx new file mode 100644 index 000000000000..df56194ce88e --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { ExceptionListHeader } from '.'; +import * as i18n from '../translations'; +import { securityLinkAnchorComponentMock } from '../mocks/security_link_component.mock'; + +import { useExceptionListHeader as useExceptionListHeaderMock } from './use_list_header'; +const onEditListDetails = jest.fn(); +const onExportList = jest.fn(); +const onDeleteList = jest.fn(); +const onManageRules = jest.fn(); +jest.mock('./use_list_header'); + +describe('ExceptionListHeader', () => { + beforeAll(() => { + (useExceptionListHeaderMock as jest.Mock).mockReturnValue({ + isModalVisible: false, + listDetails: { name: 'List Name', description: '' }, + onSave: jest.fn(), + onCancel: jest.fn(), + }); + }); + it('should render the List Header with name, default description and disabled actions because of the ReadOnly mode', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + fireEvent.click(wrapper.getByTestId('RightSideMenuItemsContainer')); + expect(wrapper.queryByTestId('MenuActions')).not.toBeInTheDocument(); + expect(wrapper.getByTestId('DescriptionText')).toHaveTextContent( + i18n.EXCEPTION_LIST_HEADER_DESCRIPTION + ); + expect(wrapper.queryByTestId('EditTitleIcon')).not.toBeInTheDocument(); + expect(wrapper.getByTestId('ListID')).toHaveTextContent( + `${i18n.EXCEPTION_LIST_HEADER_LIST_ID}:List_Id` + ); + expect(wrapper.getByTestId('Breadcrumb')).toHaveTextContent( + i18n.EXCEPTION_LIST_HEADER_BREADCRUMB + ); + }); + it('should render the List Header with name, default description and actions', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + fireEvent.click(wrapper.getByTestId('RightSideMenuItemsContainer')); + expect(wrapper.getByTestId('DescriptionText')).toHaveTextContent( + i18n.EXCEPTION_LIST_HEADER_DESCRIPTION + ); + expect(wrapper.queryByTestId('TitleEditIcon')).toBeInTheDocument(); + expect(wrapper.queryByTestId('DescriptionEditIcon')).toBeInTheDocument(); + }); + it('should render edit modal', () => { + (useExceptionListHeaderMock as jest.Mock).mockReturnValue({ + isModalVisible: true, + listDetails: { name: 'List Name', description: 'List description' }, + onSave: jest.fn(), + onCancel: jest.fn(), + }); + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('EditModal')).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/__snapshots__/menu_items.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/__snapshots__/menu_items.test.tsx.snap new file mode 100644 index 000000000000..ab3ad9df8aa8 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/__snapshots__/menu_items.test.tsx.snap @@ -0,0 +1,248 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MenuItems should render linkedRules, manageRules and menuActions 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ , + "container":
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/index.tsx new file mode 100644 index 000000000000..1e13a8ac3d0f --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/index.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { FC, useMemo } from 'react'; +import { HeaderMenu } from '../../header_menu'; +import { headerMenuCss } from '../list_header.styles'; +import * as i18n from '../../translations'; +import { Rule } from '../../types'; +import { generateLinkedRulesMenuItems } from '../../generate_linked_rules_menu_item'; +interface MenuItemsProps { + isReadonly: boolean; + dataTestSubj?: string; + linkedRules: Rule[]; + securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + onExportList: () => void; + onDeleteList: () => void; + onManageRules: () => void; +} + +const MenuItemsComponent: FC = ({ + dataTestSubj, + linkedRules, + securityLinkAnchorComponent, + isReadonly, + onExportList, + onDeleteList, + onManageRules, +}) => { + const referencedLinks = useMemo( + () => + generateLinkedRulesMenuItems({ + leftIcon: 'check', + dataTestSubj, + linkedRules, + securityLinkAnchorComponent, + }), + [dataTestSubj, linkedRules, securityLinkAnchorComponent] + ); + return ( + + + + + + + { + if (typeof onExportList === 'function') onManageRules(); + }} + > + {i18n.EXCEPTION_LIST_HEADER_MANAGE_RULES_BUTTON} + + + + + { + if (typeof onExportList === 'function') onExportList(); + }, + }, + { + key: '2', + icon: 'trash', + label: i18n.EXCEPTION_LIST_HEADER_DELETE_ACTION, + onClick: () => { + if (typeof onDeleteList === 'function') onDeleteList(); + }, + }, + ]} + disableActions={isReadonly} + anchorPosition="downCenter" + /> + + + ); +}; + +MenuItemsComponent.displayName = 'MenuItemsComponent'; + +export const MenuItems = React.memo(MenuItemsComponent); + +MenuItems.displayName = 'MenuItems'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/menu_items.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/menu_items.test.tsx new file mode 100644 index 000000000000..95d03a5b4678 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/menu_items.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { MenuItems } from '.'; +import { rules } from '../../mocks/rule_references.mock'; +import { securityLinkAnchorComponentMock } from '../../mocks/security_link_component.mock'; + +const onExportList = jest.fn(); +const onDeleteList = jest.fn(); +const onManageRules = jest.fn(); +describe('MenuItems', () => { + it('should render linkedRules, manageRules and menuActions', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('LinkedRulesMenuItems')).toHaveTextContent('Linked to 1 rules'); + expect(wrapper.getByTestId('ManageRulesButton')).toBeInTheDocument(); + expect(wrapper.getByTestId('MenuActionsButtonIcon')).toBeInTheDocument(); + }); + it('should call onManageRules', () => { + const wrapper = render( + + ); + fireEvent.click(wrapper.getByTestId('ManageRulesButton')); + expect(onManageRules).toHaveBeenCalled(); + }); + it('should call onExportList', () => { + const wrapper = render( + + ); + fireEvent.click(wrapper.getByTestId('MenuActionsButtonIcon')); + fireEvent.click(wrapper.getByTestId('MenuActionsActionItem1')); + + expect(onExportList).toHaveBeenCalled(); + }); + it('should call onDeleteList', () => { + const wrapper = render( + + ); + fireEvent.click(wrapper.getByTestId('MenuActionsButtonIcon')); + fireEvent.click(wrapper.getByTestId('MenuActionsActionItem2')); + + expect(onDeleteList).toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.test.ts b/packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.test.ts new file mode 100644 index 000000000000..9ddd782e132c --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { waitFor } from '@testing-library/dom'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { useExceptionListHeader } from './use_list_header'; + +describe('useExceptionListHeader', () => { + const onEditListDetails = jest.fn(); + it('should return the default values', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionListHeader({ name: 'list name', description: '', onEditListDetails }) + ); + const { isModalVisible, listDetails } = current; + expect(isModalVisible).toBeFalsy(); + expect(listDetails).toStrictEqual({ name: 'list name', description: '' }); + }); + it('should change the isModalVisible to be true when onEdit is called', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionListHeader({ name: 'list name', description: '', onEditListDetails }) + ); + const { isModalVisible, onEdit } = current; + act(() => { + onEdit(); + }); + + waitFor(() => { + expect(isModalVisible).toBeTruthy(); + }); + }); + + it('should call onEditListDetails with the new details after editing', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionListHeader({ name: 'list name', description: '', onEditListDetails }) + ); + const { isModalVisible, onEdit } = current; + act(() => { + onEdit(); + }); + + waitFor(() => { + expect(isModalVisible).toBeTruthy(); + }); + + const { onSave } = current; + act(() => { + onSave({ name: 'New name', description: 'New Description' }); + }); + + waitFor(() => { + expect(isModalVisible).toBeFalsy(); + expect(onEditListDetails).toBeCalledWith({ + name: 'New name', + description: 'New Description', + }); + }); + }); + it('should close the Modal when the cancel is called', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionListHeader({ name: 'list name', description: '', onEditListDetails }) + ); + const { isModalVisible, onEdit } = current; + act(() => { + onEdit(); + }); + + waitFor(() => { + expect(isModalVisible).toBeTruthy(); + }); + + const { onCancel } = current; + act(() => { + onCancel(); + }); + + waitFor(() => { + expect(isModalVisible).toBeFalsy(); + }); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.ts b/packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.ts new file mode 100644 index 000000000000..01ddbf1ac68c --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { useState } from 'react'; +import { ListDetails } from '../types'; + +interface UseExceptionListHeaderProps { + name: string; + description?: string; + onEditListDetails: (listDetails: ListDetails) => void; +} +export const useExceptionListHeader = ({ + name, + description, + onEditListDetails, +}: UseExceptionListHeaderProps) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const [listDetails, setListDetails] = useState({ name, description }); + const onEdit = () => { + setIsModalVisible(true); + }; + const onSave = (newListDetails: ListDetails) => { + setIsModalVisible(false); + setListDetails(newListDetails); + if (typeof onEditListDetails === 'function') onEditListDetails(newListDetails); + }; + const onCancel = () => { + setIsModalVisible(false); + }; + + return { + isModalVisible, + listDetails, + onEdit, + onSave, + onCancel, + }; +}; diff --git a/packages/kbn-securitysolution-exception-list-components/src/test_helpers/comments.mock.ts b/packages/kbn-securitysolution-exception-list-components/src/mocks/comments.mock.tsx similarity index 78% rename from packages/kbn-securitysolution-exception-list-components/src/test_helpers/comments.mock.ts rename to packages/kbn-securitysolution-exception-list-components/src/mocks/comments.mock.tsx index 3e83aa53f0f2..3d562f1aa316 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/test_helpers/comments.mock.ts +++ b/packages/kbn-securitysolution-exception-list-components/src/mocks/comments.mock.tsx @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import React from 'react'; import type { Comment, CommentsArray } from '@kbn/securitysolution-io-ts-list-types'; export const getCommentsMock = (): Comment => ({ @@ -16,3 +17,9 @@ export const getCommentsMock = (): Comment => ({ }); export const getCommentsArrayMock = (): CommentsArray => [getCommentsMock(), getCommentsMock()]; + +export const mockGetFormattedComments = () => + getCommentsArrayMock().map((comment) => ({ + username: comment.created_by, + children:

{comment.comment}

, + })); diff --git a/packages/kbn-securitysolution-exception-list-components/src/mocks/entry.mock.ts b/packages/kbn-securitysolution-exception-list-components/src/mocks/entry.mock.ts new file mode 100644 index 000000000000..7b82d120a947 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/mocks/entry.mock.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Entry } from '../exception_item_card/conditions/types'; + +export const includedListTypeEntry: Entry = { + field: '', + operator: 'included', + type: 'list', + list: { id: 'list_id', type: 'boolean' }, +}; + +export const includedMatchTypeEntry: Entry = { + field: '', + operator: 'included', + type: 'match', + value: 'matches value', +}; + +export const includedExistsTypeEntry: Entry = { + field: '', + operator: 'included', + type: 'exists', +}; diff --git a/packages/kbn-securitysolution-exception-list-components/src/test_helpers/exception_list_item_schema.mock.ts b/packages/kbn-securitysolution-exception-list-components/src/mocks/exception_list_item_schema.mock.ts similarity index 100% rename from packages/kbn-securitysolution-exception-list-components/src/test_helpers/exception_list_item_schema.mock.ts rename to packages/kbn-securitysolution-exception-list-components/src/mocks/exception_list_item_schema.mock.ts diff --git a/packages/kbn-securitysolution-exception-list-components/src/mocks/header.mock.ts b/packages/kbn-securitysolution-exception-list-components/src/mocks/header.mock.ts new file mode 100644 index 000000000000..55e3199e0a99 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/mocks/header.mock.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +export const handleEdit = jest.fn(); +export const handleDelete = jest.fn(); +export const actions = [ + { + key: 'edit', + icon: 'pencil', + label: 'Edit detection exception', + onClick: handleEdit, + }, + { + key: 'delete', + icon: 'trash', + label: 'Delete detection exception', + onClick: handleDelete, + }, +]; diff --git a/packages/kbn-securitysolution-exception-list-components/src/mocks/rule_references.mock.ts b/packages/kbn-securitysolution-exception-list-components/src/mocks/rule_references.mock.ts new file mode 100644 index 000000000000..d2a5e0ab0a75 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/mocks/rule_references.mock.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Rule, RuleReference } from '../types'; + +export const rules: Rule[] = [ + { + exception_lists: [ + { + id: '123', + list_id: 'i_exist', + namespace_type: 'single', + type: 'detection', + }, + { + id: '456', + list_id: 'i_exist_2', + namespace_type: 'single', + type: 'detection', + }, + ], + id: '1a2b3c', + name: 'Simple Rule Query', + rule_id: 'rule-2', + }, +]; + +export const ruleReference: RuleReference = { + name: 'endpoint list', + id: 'endpoint_list', + referenced_rules: rules, + listId: 'endpoint_list_id', +}; + +export const ruleReferences = { + endpoint_list_id: ruleReference, +}; diff --git a/packages/kbn-securitysolution-exception-list-components/src/mocks/security_link_component.mock.tsx b/packages/kbn-securitysolution-exception-list-components/src/mocks/security_link_component.mock.tsx new file mode 100644 index 000000000000..db0a64affc18 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/mocks/security_link_component.mock.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { ReactElement } from 'react'; +import { generateLinkedRulesMenuItems } from '../generate_linked_rules_menu_item'; +import { rules } from './rule_references.mock'; +export const securityLinkAnchorComponentMock = ({ + referenceName, + referenceId, +}: { + referenceName: string; + referenceId: string; +}) => ( + +); + +export const getSecurityLinkAction = (dataTestSubj: string) => + generateLinkedRulesMenuItems({ + dataTestSubj, + linkedRules: [ + ...rules, + { + exception_lists: [], + id: '2a2b3c', + name: 'Simple Rule Query 2', + rule_id: 'rule-2', + }, + ], + securityLinkAnchorComponent: securityLinkAnchorComponentMock, + }) as ReactElement[]; diff --git a/packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.tsx b/packages/kbn-securitysolution-exception-list-components/src/search_bar/index.tsx similarity index 92% rename from packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.tsx rename to packages/kbn-securitysolution-exception-list-components/src/search_bar/index.tsx index bb8dc6ee6255..a40393bac8fc 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/search_bar/index.tsx @@ -9,7 +9,7 @@ import React, { useCallback } from 'react'; import type { FC } from 'react'; -import type { SearchFilterConfig } from '@elastic/eui'; +import type { IconType, SearchFilterConfig } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiSearchBar } from '@elastic/eui'; import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { GetExceptionItemProps } from '../types'; @@ -55,6 +55,8 @@ interface SearchBarProps { isSearching?: boolean; dataTestSubj?: string; filters?: SearchFilterConfig[]; // TODO about filters + isButtonFilled?: boolean; + buttonIconType?: IconType; onSearch: (arg: GetExceptionItemProps) => void; onAddExceptionClick: (type: ExceptionListTypeEnum) => void; } @@ -66,6 +68,8 @@ const SearchBarComponent: FC = ({ isSearching, dataTestSubj, filters = [], + isButtonFilled = true, + buttonIconType, onSearch, onAddExceptionClick, }) => { @@ -101,7 +105,8 @@ const SearchBarComponent: FC = ({ data-test-subj={`${dataTestSubj || ''}Button`} onClick={handleAddException} isDisabled={isSearching} - fill + fill={isButtonFilled} + iconType={buttonIconType} > {addExceptionButtonText} diff --git a/packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.test.tsx index ac82bb3b6e85..e6efe4eefb29 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.test.tsx @@ -11,7 +11,7 @@ import { fireEvent, render } from '@testing-library/react'; import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { SearchBar } from './search_bar'; +import { SearchBar } from '.'; describe('SearchBar', () => { it('it does not display add exception button if user is read only', () => { diff --git a/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/__snapshots__/text_with_edit.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/__snapshots__/text_with_edit.test.tsx.snap new file mode 100644 index 000000000000..4543f84553ae --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/__snapshots__/text_with_edit.test.tsx.snap @@ -0,0 +1,189 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TextWithEdit should not render the edit icon when isReadonly is true 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+ + Test + +
+
+ , + "container":
+
+ + Test + +
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`TextWithEdit should render the edit icon when isReadonly is false 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+ + Test + + +
+
+ , + "container":
+
+ + Test + + +
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/index.tsx new file mode 100644 index 000000000000..5b56b2705339 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/index.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { EuiButtonIcon } from '@elastic/eui'; +import { Interpolation, Theme } from '@emotion/react'; +import { textWithEditContainerCss } from '../list_header/list_header.styles'; + +interface TextWithEditProps { + isReadonly: boolean; + dataTestSubj?: string; + text: string; + textCss?: Interpolation; + onEdit?: () => void; +} + +const TextWithEditComponent: FC = ({ + isReadonly, + dataTestSubj, + text, + onEdit, + textCss, +}) => { + return ( +
+ + {text} + + {isReadonly ? null : ( + (typeof onEdit === 'function' ? onEdit() : null)} + /> + )} +
+ ); +}; +TextWithEditComponent.displayName = 'TextWithEditComponent'; + +export const TextWithEdit = React.memo(TextWithEditComponent); + +TextWithEdit.displayName = 'TextWithEdit'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/text_with_edit.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/text_with_edit.test.tsx new file mode 100644 index 000000000000..a6973830e1d4 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/text_with_edit.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { TextWithEdit } from '.'; + +describe('TextWithEdit', () => { + it('should not render the edit icon when isReadonly is true', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('TextWithEditTestText')).toHaveTextContent('Test'); + expect(wrapper.queryByTestId('TextWithEditTestEditIcon')).not.toBeInTheDocument(); + }); + it('should render the edit icon when isReadonly is false', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('TextWithEditTestText')).toHaveTextContent('Test'); + expect(wrapper.getByTestId('TextWithEditTestEditIcon')).toBeInTheDocument(); + }); + it('should not call onEdit', () => { + const onEdit = ''; + const wrapper = render( + + ); + const editIcon = wrapper.getByTestId('TextWithEditTestEditIcon'); + expect(wrapper.getByTestId('TextWithEditTestText')).toHaveTextContent('Test'); + expect(editIcon).toBeInTheDocument(); + fireEvent.click(editIcon); + }); + it('should call onEdit', () => { + const onEdit = jest.fn(); + + const wrapper = render( + + ); + expect(wrapper.getByTestId('TextWithEditTestText')).toHaveTextContent('Test'); + expect(wrapper.queryByTestId('TextWithEditTestEditIcon')).toBeInTheDocument(); + fireEvent.click(wrapper.getByTestId('TextWithEditTestEditIcon')); + expect(onEdit).toBeCalled(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/translations.ts b/packages/kbn-securitysolution-exception-list-components/src/translations.ts index c919ef423c54..c5740958abcf 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/translations.ts +++ b/packages/kbn-securitysolution-exception-list-components/src/translations.ts @@ -55,3 +55,88 @@ export const EMPTY_VIEWER_STATE_ERROR_BODY = i18n.translate( 'There was an error loading the exception items. Contact your administrator for help.', } ); +export const EXCEPTION_LIST_HEADER_EXPORT_ACTION = i18n.translate( + 'exceptionList-components.exception_list_header_export_action', + { + defaultMessage: 'Export exception list', + } +); +export const EXCEPTION_LIST_HEADER_DELETE_ACTION = i18n.translate( + 'exceptionList-components.exception_list_header_delete_action', + { + defaultMessage: 'Delete exception list', + } +); +export const EXCEPTION_LIST_HEADER_MANAGE_RULES_BUTTON = i18n.translate( + 'exceptionList-components.exception_list_header_manage_rules_button', + { + defaultMessage: 'Manage rules', + } +); + +export const EXCEPTION_LIST_HEADER_LINKED_RULES = (noOfRules: number) => + i18n.translate('exceptionList-components.exception_list_header_linked_rules', { + values: { noOfRules }, + defaultMessage: 'Linked to {noOfRules} rules', + }); + +export const EXCEPTION_LIST_HEADER_BREADCRUMB = i18n.translate( + 'exceptionList-components.exception_list_header_breadcrumb', + { + defaultMessage: 'Rule exceptions', + } +); + +export const EXCEPTION_LIST_HEADER_LIST_ID = i18n.translate( + 'exceptionList-components.exception_list_header_list_id', + { + defaultMessage: 'List ID', + } +); + +export const EXCEPTION_LIST_HEADER_NAME = i18n.translate( + 'exceptionList-components.exception_list_header_name', + { + defaultMessage: 'Add a name', + } +); + +export const EXCEPTION_LIST_HEADER_DESCRIPTION = i18n.translate( + 'exceptionList-components.exception_list_header_description', + { + defaultMessage: 'Add a description', + } +); + +export const EXCEPTION_LIST_HEADER_EDIT_MODAL_TITLE = (listName: string) => + i18n.translate('exceptionList-components.exception_list_header_edit_modal_name', { + defaultMessage: 'Edit {listName}', + values: { listName }, + }); + +export const EXCEPTION_LIST_HEADER_EDIT_MODAL_SAVE_BUTTON = i18n.translate( + 'exceptionList-components.exception_list_header_edit_modal_save_button', + { + defaultMessage: 'Save', + } +); + +export const EXCEPTION_LIST_HEADER_EDIT_MODAL_CANCEL_BUTTON = i18n.translate( + 'exceptionList-components.exception_list_header_edit_modal_cancel_button', + { + defaultMessage: 'Cancel', + } +); +export const EXCEPTION_LIST_HEADER_NAME_TEXTBOX = i18n.translate( + 'exceptionList-components.exception_list_header_Name_textbox', + { + defaultMessage: 'Name', + } +); + +export const EXCEPTION_LIST_HEADER_DESCRIPTION_TEXTBOX = i18n.translate( + 'exceptionList-components.exception_list_header_description_textbox', + { + defaultMessage: 'Description', + } +); diff --git a/packages/kbn-securitysolution-exception-list-components/src/types/index.ts b/packages/kbn-securitysolution-exception-list-components/src/types/index.ts index dbb402ca7845..d799879916de 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/types/index.ts +++ b/packages/kbn-securitysolution-exception-list-components/src/types/index.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { ListArray } from '@kbn/securitysolution-io-ts-list-types'; import type { Pagination } from '@elastic/eui'; import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; @@ -42,7 +42,7 @@ export interface ExceptionListSummaryProps { export type ViewerFlyoutName = 'addException' | 'editException' | null; export interface RuleReferences { - [key: string]: any[]; // TODO fix + [key: string]: RuleReference; } export interface ExceptionListItemIdentifiers { @@ -56,10 +56,21 @@ export enum ListTypeText { DETECTION = 'empty', RULE_DEFAULT = 'empty_search', } +export interface Rule { + name: string; + id: string; + rule_id: string; + exception_lists: ListArray; +} export interface RuleReference { name: string; id: string; - ruleId: string; - exceptionLists: ExceptionListSchema[]; + referenced_rules: Rule[]; + listId?: string; +} + +export interface ListDetails { + name: string; + description?: string; } From 4804ce2a46cae4ec539c460498cef1e73458e81a Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Wed, 19 Oct 2022 19:47:15 +0200 Subject: [PATCH 05/43] Fleet usages generic shipper (#143647) * removed custom shipper * removed custom shipper * updated registerEventType * extracted event type to a constant --- .../fleet/server/services/fleet_usage_sender.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/fleet_usage_sender.ts b/x-pack/plugins/fleet/server/services/fleet_usage_sender.ts index b7ab806e5f8c..ada764fcff92 100644 --- a/x-pack/plugins/fleet/server/services/fleet_usage_sender.ts +++ b/x-pack/plugins/fleet/server/services/fleet_usage_sender.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -/* eslint-disable max-classes-per-file */ import type { ConcreteTaskInstance, TaskManagerStartContract, @@ -12,15 +11,11 @@ import type { } from '@kbn/task-manager-plugin/server'; import type { CoreSetup } from '@kbn/core/server'; -import { ElasticV3ServerShipper } from '@kbn/analytics-shippers-elastic-v3-server'; - import type { Usage } from '../collectors/register'; import { appContextService } from './app_context'; -export class FleetShipper extends ElasticV3ServerShipper { - public static shipperName = 'fleet_shipper'; -} +const EVENT_TYPE = 'fleet_usage'; export class FleetUsageSender { private taskManager?: TaskManagerStartContract; @@ -47,7 +42,7 @@ export class FleetUsageSender { try { const usageData = await fetchUsage(); appContextService.getLogger().debug(JSON.stringify(usageData)); - core.analytics.reportEvent('Fleet Usage', usageData); + core.analytics.reportEvent(EVENT_TYPE, usageData); } catch (error) { appContextService .getLogger() @@ -61,12 +56,6 @@ export class FleetUsageSender { }, }); this.registerTelemetryEventType(core); - - core.analytics.registerShipper(FleetShipper, { - channelName: 'fleet-usages', - version: kibanaVersion, - sendTo: isProductionMode ? 'production' : 'staging', - }); } public async start(taskManager: TaskManagerStartContract) { @@ -90,7 +79,7 @@ export class FleetUsageSender { */ private registerTelemetryEventType(core: CoreSetup): void { core.analytics.registerEventType({ - eventType: 'Fleet Usage', + eventType: EVENT_TYPE, schema: { agents_enabled: { type: 'boolean', _meta: { description: 'agents enabled' } }, agents: { From 3d33abe9576f70ae5a79429f2a26e61773bcb28f Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 19 Oct 2022 14:31:30 -0400 Subject: [PATCH 06/43] [Security Solution][Endpoint] Endpoint agent emulator CLI Dev tool (#143325) * Initial structure for agent emulator with keep alive service * add way to generate a fleetServerAgent with different statuses * fetchEndpointMetadataList service * keep agents and endpoints alive * add ability to generate random agent status on checkin * remove fleet agent checkin from the `sendEndpointMetadataUpdate()` * Adjust the Fleet agent checking update * Add logger to fleet service for agent checkin * move modules to services directory * new based class for running processes + refactor of keep_alive to use it * CLI options + action responder service incorporated ++ improvements to BaseRunningService * prevent `inactive` update to fleet agent + bug fix in cli args * text correction * refactor: move tool's runner functio to its own module * Moved base class for running services to `common` dir * Moved init of services to `EmulatorContext` * Load endpoints on start if none are in the system * Initial modules for displaying screens * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * handle correct behaviours for prompt and ability to show messages * move `ScreenBaseClass` to `lib/` dir * `Q` now quites the CLI tool * Load endpoints screen navigation from main menu * Improvements to ChoiceLIstFormatter + ScreenBaseClass * Add endpoint loader service for loading endpoints (w/ no alerts) * Add jsdocs to screen common classes and methods * Refactor: move generic screen modules to `endpoint/common/screen` for reuse * new ProgressFormatter * load endpoints screens views for prompt and loading * Ability to pause and reRender screens * Change data formatters to NOT include new lines * add `leftPad()` to screen base class + use it in body/footer * Add done view to load endpoints * Improvements to Progress formatter * Load Endpoint now indexes the hosts * Removed file unrelated to this PR * Remove logic from Action list API that was returning `404` of action's index did not exist * Fix missing import * Create `endpoint_response_actions` service and move methods from prior tool * Rename service file * Rename ChoiceListFormatter to ChoiceMenuFormatter * changed options on Load Endpoints screen * Load endpoints support for configuration * Some coloring styling of screens * new common service for settings storage * persist endpoint loader settings to disk * add version to tool's settings * Use consts for index names * change arguments to `loadEndpoints` * Improve error reporting of keep alive service * Check agent in (by default) as healthy * Column Layout Formatter - display columns of content * Add additional options to Column layout + add running state to main screen * Renamed of running service * Delete Action Responder stand-alone script * Actions Responder Screen * Allow Action Responder to be turned off * start/stop action responder * Change endponit loader concurrency to avoid EventEmitter warnings * Fix test for action list handler * Revert "Remove logic from Action list API that was returning `404` of action's index did not exist" This reverts commit f0e25daeea6c9b45d1452fa60719bbf697ed191c. * Handle Action List API 404 * Revert "Fix test for action list handler" This reverts commit 3dae83e9b0570fc258b34582592995c3f484f796. * changes from code review (by David) Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../data_generators/fleet_agent_generator.ts | 79 +++- .../endpoint/action_responder/constants.ts | 19 - .../endpoint/action_responder/index.ts | 68 ---- .../action_responder/run_in_auto_mode.ts | 131 ------- .../endpoint/agent_emulator/agent_emulator.ts | 41 ++ .../endpoint/agent_emulator/constants.ts | 8 + .../scripts/endpoint/agent_emulator/index.ts | 52 +++ .../screens/actions_responder.ts | 71 ++++ .../run_service_status_formatter.ts | 29 ++ .../agent_emulator/screens/load_endpoints.ts | 224 +++++++++++ .../endpoint/agent_emulator/screens/main.ts | 98 +++++ .../services/action_responder.ts | 122 ++++++ .../services/agent_keep_alive.ts | 71 ++++ .../services/emulator_run_context.ts | 155 ++++++++ .../services/endpoint_loader.ts | 182 +++++++++ .../services/endpoint_response_actions.ts} | 81 ++-- .../scripts/endpoint/agent_emulator/types.ts | 16 + .../endpoint/common/base_running_service.ts | 108 ++++++ .../scripts/endpoint/common/constants.ts | 12 + .../common/endpoint_metadata_services.ts | 53 ++- .../scripts/endpoint/common/fleet_services.ts | 78 +++- .../common/screen/choice_menu_formatter.ts | 61 +++ .../common/screen/column_layout_formatter.ts | 87 +++++ .../endpoint/common/screen/common_choices.ts | 16 + .../endpoint/common/screen/constants.ts | 10 + .../endpoint/common/screen/data_formatter.ts | 23 ++ .../scripts/endpoint/common/screen/index.ts | 13 + .../common/screen/progress_formatter.ts | 29 ++ .../common/screen/screen_base_class.ts | 364 ++++++++++++++++++ .../endpoint/common/screen/type_gards.ts | 17 + .../scripts/endpoint/common/screen/types.ts | 16 + .../endpoint/common/settings_storage.ts | 74 ++++ ...esponder.js => endpoint_agent_emulator.js} | 2 +- .../endpoint/resolver_generator_script.ts | 8 +- 34 files changed, 2145 insertions(+), 273 deletions(-) delete mode 100644 x-pack/plugins/security_solution/scripts/endpoint/action_responder/constants.ts delete mode 100644 x-pack/plugins/security_solution/scripts/endpoint/action_responder/index.ts delete mode 100644 x-pack/plugins/security_solution/scripts/endpoint/action_responder/run_in_auto_mode.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/agent_emulator.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/constants.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/index.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/actions_responder.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/components/run_service_status_formatter.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/load_endpoints.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/main.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/action_responder.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/agent_keep_alive.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/emulator_run_context.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_loader.ts rename x-pack/plugins/security_solution/scripts/endpoint/{action_responder/utils.ts => agent_emulator/services/endpoint_response_actions.ts} (77%) create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/types.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/base_running_service.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/constants.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/screen/choice_menu_formatter.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/screen/column_layout_formatter.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/screen/common_choices.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/screen/constants.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/screen/data_formatter.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/screen/index.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/screen/progress_formatter.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/screen/screen_base_class.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/screen/type_gards.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/screen/types.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/settings_storage.ts rename x-pack/plugins/security_solution/scripts/endpoint/{endpoint_action_responder.js => endpoint_agent_emulator.js} (89%) diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_agent_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_agent_generator.ts index 127e69b86230..d173798dcf87 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_agent_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_agent_generator.ts @@ -14,9 +14,12 @@ import type { FleetServerAgent, FleetServerAgentComponentStatus, } from '@kbn/fleet-plugin/common'; -import { AGENTS_INDEX, FleetServerAgentComponentStatuses } from '@kbn/fleet-plugin/common'; +import { FleetServerAgentComponentStatuses, AGENTS_INDEX } from '@kbn/fleet-plugin/common'; +import moment from 'moment'; import { BaseDataGenerator } from './base_data_generator'; +// List of computed (as in, done in code is kibana via +// https://github.com/elastic/kibana/blob/main/x-pack/plugins/fleet/common/services/agent_status.ts#L13-L44 const agentStatusList: readonly AgentStatus[] = [ 'offline', 'error', @@ -29,6 +32,13 @@ const agentStatusList: readonly AgentStatus[] = [ 'degraded', ]; +const lastCheckinStatusList: ReadonlyArray = [ + 'error', + 'online', + 'degraded', + 'updating', +]; + export class FleetAgentGenerator extends BaseDataGenerator { /** * @param [overrides] any partial value to the full Agent record @@ -138,8 +148,6 @@ export class FleetAgentGenerator extends BaseDataGenerator { policy_id: this.randomUUID(), type: 'PERMANENT', default_api_key: 'so3dWnkBj1tiuAw9yAm3:t7jNlnPnR6azEI_YpXuBXQ', - // policy_output_permissions_hash: - // '81b3d070dddec145fafcbdfb6f22888495a12edc31881f6b0511fa10de66daa7', default_api_key_id: 'so3dWnkBj1tiuAw9yAm3', updated_at: now, last_checkin: now, @@ -171,13 +179,76 @@ export class FleetAgentGenerator extends BaseDataGenerator { ], }, ], + last_checkin_status: this.randomChoice(lastCheckinStatusList), + upgraded_at: null, + upgrade_started_at: null, + unenrolled_at: undefined, + unenrollment_started_at: undefined, }, }, overrides ); } - private randomAgentStatus() { + generateEsHitWithStatus( + status: AgentStatus, + overrides: DeepPartial> = {} + ) { + const esHit = this.generateEsHit(overrides); + + // Basically: reverse engineer the Fleet `getAgentStatus()` utility: + // https://github.com/elastic/kibana/blob/main/x-pack/plugins/fleet/common/services/agent_status.ts#L13-L44 + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const fleetServerAgent = esHit._source!; + + // Reset the `last_checkin_status since we're controlling the agent status here + fleetServerAgent.last_checkin_status = 'online'; + + switch (status) { + case 'degraded': + fleetServerAgent.last_checkin_status = 'degraded'; + break; + + case 'enrolling': + fleetServerAgent.last_checkin = undefined; + + break; + case 'error': + fleetServerAgent.last_checkin_status = 'error'; + break; + + case 'inactive': + fleetServerAgent.active = false; + break; + + case 'offline': + // current fleet timeout interface for offline is 5 minutes + // https://github.com/elastic/kibana/blob/main/x-pack/plugins/fleet/common/services/agent_status.ts#L11 + fleetServerAgent.last_checkin = moment().subtract(6, 'minutes').toISOString(); + break; + + case 'unenrolling': + fleetServerAgent.unenrollment_started_at = fleetServerAgent.updated_at; + fleetServerAgent.unenrolled_at = undefined; + break; + + case 'updating': + fleetServerAgent.upgrade_started_at = fleetServerAgent.updated_at; + fleetServerAgent.upgraded_at = undefined; + break; + + case 'warning': + // NOt able to find anything in fleet + break; + + // default is `online`, which is also the default returned by `generateEsHit()` + } + + return esHit; + } + + public randomAgentStatus() { return this.randomChoice(agentStatusList); } } diff --git a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/constants.ts b/x-pack/plugins/security_solution/scripts/endpoint/action_responder/constants.ts deleted file mode 100644 index 23c3292da66e..000000000000 --- a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/constants.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const HORIZONTAL_LINE = '-'.repeat(80); - -export const SUPPORTED_TOKENS = `The following tokens can be used in the Action request 'comment' to drive - the type of response that is sent: - Token Description - --------------------------- ------------------------------------------------------- - RESPOND.STATE=SUCCESS Will ensure the Endpoint Action response is success - RESPOND.STATE=FAILURE Will ensure the Endpoint Action response is a failure - RESPOND.FLEET.STATE=SUCCESS Will ensure the Fleet Action response is success - RESPOND.FLEET.STATE=FAILURE Will ensure the Fleet Action response is a failure - -`; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/action_responder/index.ts deleted file mode 100644 index ae73a3a978d2..000000000000 --- a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/index.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { RunContext } from '@kbn/dev-cli-runner'; -import { run } from '@kbn/dev-cli-runner'; -import { HORIZONTAL_LINE, SUPPORTED_TOKENS } from './constants'; -import { runInAutoMode } from './run_in_auto_mode'; - -export const cli = () => { - run( - async (context: RunContext) => { - context.log.write(` -${HORIZONTAL_LINE} - Endpoint Action Responder -${HORIZONTAL_LINE} -`); - if (context.flags.mode === 'auto') { - return runInAutoMode(context); - } - - context.log.warning(`exiting... Nothing to do. use '--help' to see list of options`); - - context.log.write(` -${HORIZONTAL_LINE} -`); - }, - - { - description: `Respond to pending Endpoint actions. - ${SUPPORTED_TOKENS}`, - flags: { - string: ['mode', 'kibana', 'elastic', 'username', 'password', 'delay'], - boolean: ['asSuperuser'], - default: { - mode: 'auto', - kibana: 'http://localhost:5601', - elastic: 'http://localhost:9200', - username: 'elastic', - password: 'changeme', - asSuperuser: false, - delay: '', - }, - help: ` - --mode The mode for running the tool. (Default: 'auto'). - Value values are: - auto : tool will continue to run and checking for pending - actions periodically. - --username User name to be used for auth against elasticsearch and - kibana (Default: elastic). - **IMPORTANT:** This username's roles MUST have 'superuser'] - and 'kibana_system' roles - --password User name Password (Default: changeme) - --asSuperuser If defined, then a Security super user will be created using the - the credentials defined via 'username' and 'password' options. This - new user will then be used to run this utility. - --delay The delay (in milliseconds) that should be applied before responding - to an action. (Default: 40000 (40s)) - --kibana The url to Kibana (Default: http://localhost:5601) - --elastic The url to Elasticsearch (Default: http:localholst:9200) - `, - }, - } - ); -}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/run_in_auto_mode.ts b/x-pack/plugins/security_solution/scripts/endpoint/action_responder/run_in_auto_mode.ts deleted file mode 100644 index 8e93cf7625b7..000000000000 --- a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/run_in_auto_mode.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { RunContext } from '@kbn/dev-cli-runner'; -import { set } from 'lodash'; -import { SUPPORTED_TOKENS } from './constants'; -import type { ActionDetails } from '../../../common/endpoint/types'; -import type { RuntimeServices } from '../common/stack_services'; -import { createRuntimeServices } from '../common/stack_services'; - -import { - fetchEndpointActionList, - sendEndpointActionResponse, - sendFleetActionResponse, - sleep, -} from './utils'; - -const ACTION_RESPONSE_DELAY = 40_000; - -export const runInAutoMode = async ({ - log, - flags: { username, password, asSuperuser, kibana, elastic, delay: _delay }, -}: RunContext) => { - const runtimeServices = await createRuntimeServices({ - log, - password: password as string, - username: username as string, - asSuperuser: asSuperuser as boolean, - elasticsearchUrl: elastic as string, - kibanaUrl: kibana as string, - }); - - log.write(` ${SUPPORTED_TOKENS}`); - - const delay = Number(_delay) || ACTION_RESPONSE_DELAY; - - do { - await checkPendingActionsAndRespond(runtimeServices, { delay }); - await sleep(5_000); - } while (true); -}; - -const checkPendingActionsAndRespond = async ( - { kbnClient, esClient, log }: RuntimeServices, - { delay = ACTION_RESPONSE_DELAY }: { delay?: number } = {} -) => { - let hasMore = true; - let nextPage = 1; - - try { - while (hasMore) { - const { data: actions } = await fetchEndpointActionList(kbnClient, { - page: nextPage++, - pageSize: 100, - }); - - if (actions.length === 0) { - hasMore = false; - } - - for (const action of actions) { - if (action.isCompleted === false) { - if (Date.now() - new Date(action.startedAt).getTime() >= delay) { - log.info( - `[${new Date().toLocaleTimeString()}]: Responding to [${ - action.command - }] action [id: ${action.id}] agent: [${action.agents.join(', ')}]` - ); - - const tokens = parseCommentTokens(getActionComment(action)); - - log.verbose('tokens found in action:', tokens); - - const fleetResponse = await sendFleetActionResponse(esClient, action, { - // If an Endpoint state token was found, then force the Fleet response to `success` - // so that we can actually generate an endpoint response below. - state: tokens.state ? 'success' : tokens.fleet.state, - }); - - // If not a fleet response error, then also sent the Endpoint Response - if (!fleetResponse.error) { - await sendEndpointActionResponse(esClient, action, { state: tokens.state }); - } - } - } - } - } - } catch (e) { - log.error(`${e.message}. Run with '--verbose' option to see more`); - log.verbose(e); - } -}; - -interface CommentTokens { - state: 'success' | 'failure' | undefined; - fleet: { - state: 'success' | 'failure' | undefined; - }; -} - -const parseCommentTokens = (comment: string): CommentTokens => { - const response: CommentTokens = { - state: undefined, - fleet: { - state: undefined, - }, - }; - - if (comment) { - const findTokensRegExp = /(respond\.\S*=\S*)/gi; - let matches; - - while ((matches = findTokensRegExp.exec(comment)) !== null) { - const [key, value] = matches[0] - .toLowerCase() - .split('=') - .map((s) => s.trim()); - - set(response, key.split('.').slice(1), value); - } - } - return response; -}; - -const getActionComment = (action: ActionDetails): string => { - return action.comment ?? ''; -}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/agent_emulator.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/agent_emulator.ts new file mode 100644 index 000000000000..637befd89e2f --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/agent_emulator.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RunFn } from '@kbn/dev-cli-runner'; +import { MainScreen } from './screens/main'; +import { loadEndpointsIfNoneExist } from './services/endpoint_loader'; +import { EmulatorRunContext } from './services/emulator_run_context'; + +export const DEFAULT_CHECKIN_INTERVAL = 60_000; // 1m +export const DEFAULT_ACTION_DELAY = 5_000; // 5s + +export const agentEmulatorRunner: RunFn = async (cliContext) => { + const actionResponseDelay = Number(cliContext.flags.actionDelay) || DEFAULT_ACTION_DELAY; + const checkinInterval = Number(cliContext.flags.checkinInterval) || DEFAULT_CHECKIN_INTERVAL; + + const emulatorContext = new EmulatorRunContext({ + username: cliContext.flags.username as string, + password: cliContext.flags.password as string, + kibanaUrl: cliContext.flags.kibana as string, + elasticsearchUrl: cliContext.flags.elasticsearch as string, + asSuperuser: cliContext.flags.asSuperuser as boolean, + log: cliContext.log, + actionResponseDelay, + checkinInterval, + }); + await emulatorContext.start(); + + loadEndpointsIfNoneExist( + emulatorContext.getEsClient(), + emulatorContext.getKbnClient(), + emulatorContext.getLogger() + ); + + await new MainScreen(emulatorContext).show(); + + await emulatorContext.stop(); +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/constants.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/constants.ts new file mode 100644 index 000000000000..50b825136532 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const TOOL_TITLE = 'Endpoint Agent Emulator' as const; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/index.ts new file mode 100644 index 000000000000..5daba7f613fa --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { run } from '@kbn/dev-cli-runner'; +import { agentEmulatorRunner } from './agent_emulator'; + +const DEFAULT_CHECKIN_INTERVAL = 60_000; // 1m +const DEFAULT_ACTION_DELAY = 5_000; // 5s + +export const cli = () => { + run( + agentEmulatorRunner, + + // Options + { + description: `Endpoint agent emulator.`, + flags: { + string: ['kibana', 'elastic', 'username', 'password'], + boolean: ['asSuperuser'], + default: { + kibana: 'http://localhost:5601', + elasticsearch: 'http://localhost:9200', + username: 'elastic', + password: 'changeme', + asSuperuser: false, + actionDelay: DEFAULT_ACTION_DELAY, + checkinInterval: DEFAULT_CHECKIN_INTERVAL, + }, + help: ` + --username User name to be used for auth against elasticsearch and + kibana (Default: elastic). + **IMPORTANT:** if 'asSuperuser' option is not used, then the + user defined here MUST have 'superuser' AND 'kibana_system' roles + --password User name Password (Default: changeme) + --asSuperuser If defined, then a Security super user will be created using the + the credentials defined via 'username' and 'password' options. This + new user will then be used to run this utility. + --kibana The url to Kibana (Default: http://localhost:5601) + --elasticsearch The url to Elasticsearch (Default: http://localhost:9200) + --checkinInterval The interval between how often the Agent is checked into fleet and a + metadata document update is sent for the endpoint. Default is 1 minute + --actionDelay The delay (in milliseconds) that should be applied before responding + to an action. (Default: 5000 (5s)) + `, + }, + } + ); +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/actions_responder.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/actions_responder.ts new file mode 100644 index 000000000000..448f8907986f --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/actions_responder.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { blue } from 'chalk'; +import { HORIZONTAL_LINE } from '../../common/constants'; +import { RunServiceStatus } from './components/run_service_status_formatter'; +import { ColumnLayoutFormatter } from '../../common/screen/column_layout_formatter'; +import { TOOL_TITLE } from '../constants'; +import type { EmulatorRunContext } from '../services/emulator_run_context'; +import type { DataFormatter } from '../../common/screen'; +import { ChoiceMenuFormatter, ScreenBaseClass } from '../../common/screen'; + +export class ActionResponderScreen extends ScreenBaseClass { + constructor(private readonly emulatorContext: EmulatorRunContext) { + super(); + } + + protected header() { + return super.header(TOOL_TITLE, 'Actions Responder'); + } + + protected body(): string | DataFormatter { + const isServiceRunning = this.emulatorContext.getActionResponderService().isRunning; + const actionsAndStatus = new ColumnLayoutFormatter([ + new ChoiceMenuFormatter([isServiceRunning ? 'Stop Service' : 'Start Service']), + `Status: ${new RunServiceStatus(isServiceRunning).output}`, + ]); + + return `Service checks for new Endpoint Actions and automatically responds to them. + The following tokens can be used in the Action request 'comment' to drive + the type of response that is sent: + Token Description + --------------------------- ------------------------------------ + RESPOND.STATE=SUCCESS Respond with success + RESPOND.STATE=FAILURE Respond with failure + RESPOND.FLEET.STATE=SUCCESS Respond to Fleet Action with success + RESPOND.FLEET.STATE=FAILURE Respond to Fleet Action with failure + +${blue(HORIZONTAL_LINE.substring(0, HORIZONTAL_LINE.length - 2))} + ${actionsAndStatus.output}`; + } + + protected onEnterChoice(choice: string) { + const choiceValue = choice.trim().toUpperCase(); + + switch (choiceValue) { + case 'Q': + this.hide(); + return; + + case '1': + { + const actionsResponderService = this.emulatorContext.getActionResponderService(); + const isRunning = actionsResponderService.isRunning; + if (isRunning) { + actionsResponderService.stop(); + } else { + actionsResponderService.start(); + } + } + this.reRender(); + return; + } + + this.throwUnknownChoiceError(choice); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/components/run_service_status_formatter.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/components/run_service_status_formatter.ts new file mode 100644 index 000000000000..273c323e56e4 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/components/run_service_status_formatter.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { bgCyan, red, dim } from 'chalk'; +import type { BaseRunningService } from '../../../common/base_running_service'; +import { DataFormatter } from '../../../common/screen'; + +export class RunServiceStatus extends DataFormatter { + constructor(private readonly serviceOrIsRunning: boolean | BaseRunningService) { + super(); + } + + protected getOutput(): string { + const isRunning = + typeof this.serviceOrIsRunning === 'boolean' + ? this.serviceOrIsRunning + : this.serviceOrIsRunning.isRunning; + + if (isRunning) { + return bgCyan(' Running '); + } + + return dim(red(' Stopped ')); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/load_endpoints.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/load_endpoints.ts new file mode 100644 index 000000000000..894a668b6fb7 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/load_endpoints.ts @@ -0,0 +1,224 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable require-atomic-updates */ + +import { blue, green } from 'chalk'; +import type { DistinctQuestion } from 'inquirer'; +import type { LoadEndpointsConfig } from '../types'; +import { loadEndpoints } from '../services/endpoint_loader'; +import type { EmulatorRunContext } from '../services/emulator_run_context'; +import { ProgressFormatter } from '../../common/screen/progress_formatter'; +import type { DataFormatter } from '../../common/screen'; +import { ChoiceMenuFormatter, ScreenBaseClass } from '../../common/screen'; +import { TOOL_TITLE } from '../constants'; + +interface LoadOptions { + count: number; + progress: ProgressFormatter; + isRunning: boolean; + isDone: boolean; +} + +const promptQuestion = ( + options: DistinctQuestion +): DistinctQuestion => { + const question: DistinctQuestion = { + type: 'input', + name: 'Unknown?', + message: 'Unknown?', + // @ts-expect-error unclear why this is not defined in the definition file + askAnswered: true, + prefix: green(' ==> '), + ...options, + }; + + if (question.default === undefined) { + question.default = (answers: TAnswers) => { + return answers[(question.name ?? '-') as keyof TAnswers] ?? ''; + }; + } + + return question; +}; + +export class LoadEndpointsScreen extends ScreenBaseClass { + private runInfo: LoadOptions | undefined = undefined; + private choices: ChoiceMenuFormatter = new ChoiceMenuFormatter([ + { + title: 'Run', + key: '1', + }, + { + title: 'Configure', + key: '2', + }, + ]); + private config: LoadEndpointsConfig = { count: 2 }; + + constructor(private readonly emulatorContext: EmulatorRunContext) { + super(); + } + + private async loadSettings(): Promise { + const allSettings = await this.emulatorContext.getSettingsService().get(); + + this.config = allSettings.endpointLoader; + } + + private async saveSettings(): Promise { + const settingsService = this.emulatorContext.getSettingsService(); + + const allSettings = await settingsService.get(); + await settingsService.save({ + ...allSettings, + endpointLoader: this.config, + }); + } + + protected header() { + return super.header(TOOL_TITLE, 'Endpoint loader'); + } + + protected body(): string | DataFormatter { + if (this.runInfo) { + if (this.runInfo.isDone) { + return this.doneView(); + } + + return this.loadingView(); + } + + return this.mainView(); + } + + protected onEnterChoice(choice: string) { + const choiceValue = choice.trim().toUpperCase(); + + switch (choiceValue) { + case 'Q': + this.hide(); + return; + + case '1': + this.runInfo = { + count: this.config.count, + progress: new ProgressFormatter(), + isRunning: false, + isDone: false, + }; + + this.reRender(); + this.loadEndpoints(); + return; + + case '2': + this.configView(); + return; + + default: + if (!choiceValue) { + if (this.runInfo?.isDone) { + this.runInfo = undefined; + this.reRender(); + return; + } + } + + this.throwUnknownChoiceError(choice); + } + } + + private async configView() { + this.config = await this.prompt({ + questions: [ + promptQuestion({ + type: 'number', + name: 'count', + message: 'How many endpoints to load?', + validate(input: number, answers): boolean | string { + if (!Number.isFinite(input)) { + return 'Enter valid number'; + } + return true; + }, + filter(input: number): number | string { + if (Number.isNaN(input)) { + return ''; + } + return input; + }, + }), + ], + answers: this.config, + title: blue('Endpoint Loader Settings'), + }); + + await this.saveSettings(); + this.reRender(); + } + + private async loadEndpoints() { + const runInfo = this.runInfo; + + if (runInfo && !runInfo.isDone && !runInfo.isRunning) { + runInfo.isRunning = true; + + await loadEndpoints({ + count: runInfo.count, + esClient: this.emulatorContext.getEsClient(), + kbnClient: this.emulatorContext.getKbnClient(), + log: this.emulatorContext.getLogger(), + onProgress: (progress) => { + runInfo.progress.setProgress(progress.percent); + this.reRender(); + }, + }); + + runInfo.isDone = true; + runInfo.isRunning = false; + this.reRender(); + } + } + + private mainView(): string | DataFormatter { + return ` + Generate and load endpoints into elasticsearch along with associated + fleet agents. Current settings: + + Count: ${this.config.count} + + Options: + ${this.choices.output}`; + } + + private loadingView(): string | DataFormatter { + if (this.runInfo) { + return ` + + Creating ${this.runInfo.count} endpoint(s): + + ${this.runInfo.progress.output} +`; + } + + return 'Unknown state'; + } + + private doneView(): string { + return `${this.loadingView()} + + Done. Endpoint(s) have been loaded into Elastic/Kibana. + Press Enter to continue +`; + } + + async show(options: Partial<{ prompt: string; resume: boolean }> = {}): Promise { + await this.loadSettings(); + return super.show(options); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/main.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/main.ts new file mode 100644 index 000000000000..0c8722be6706 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/main.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionResponderScreen } from './actions_responder'; +import { SCREEN_ROW_MAX_WIDTH } from '../../common/screen/constants'; +import { ColumnLayoutFormatter } from '../../common/screen/column_layout_formatter'; +import type { EmulatorRunContext } from '../services/emulator_run_context'; +import { LoadEndpointsScreen } from './load_endpoints'; +import { TOOL_TITLE } from '../constants'; +import { ScreenBaseClass, ChoiceMenuFormatter } from '../../common/screen'; +import type { DataFormatter } from '../../common/screen/data_formatter'; +import { RunServiceStatus } from './components/run_service_status_formatter'; + +export class MainScreen extends ScreenBaseClass { + private readonly loadEndpointsScreen: LoadEndpointsScreen; + private readonly actionsResponderScreen: ActionResponderScreen; + + private actionColumnWidthPrc = 30; + private runningStateColumnWidthPrc = 70; + + constructor(private readonly emulatorContext: EmulatorRunContext) { + super(); + this.loadEndpointsScreen = new LoadEndpointsScreen(this.emulatorContext); + this.actionsResponderScreen = new ActionResponderScreen(this.emulatorContext); + } + + protected header(title: string = '', subTitle: string = ''): string | DataFormatter { + return super.header(TOOL_TITLE); + } + + protected body(): string | DataFormatter { + return `\n${ + new ColumnLayoutFormatter([this.getMenuOptions(), this.runStateView()], { + widths: [this.actionColumnWidthPrc, this.runningStateColumnWidthPrc], + }).output + }`; + } + + private getMenuOptions(): ChoiceMenuFormatter { + return new ChoiceMenuFormatter(['Load endpoints', 'Actions Responder']); + } + + private runStateView(): ColumnLayoutFormatter { + const context = this.emulatorContext; + + return new ColumnLayoutFormatter( + [ + ['Agent Keep Alive Service', 'Actions Responder Service'].join('\n'), + [ + new RunServiceStatus(context.getAgentKeepAliveService()).output, + new RunServiceStatus(context.getActionResponderService()).output, + ].join('\n'), + ], + { + rowLength: Math.floor(SCREEN_ROW_MAX_WIDTH * (this.runningStateColumnWidthPrc / 100)), + separator: ': ', + widths: [70, 30], + } + ); + } + + protected footer(): string | DataFormatter { + return super.footer([ + { + key: 'E', + title: 'Exit', + }, + ]); + } + + protected onEnterChoice(choice: string) { + switch (choice.toUpperCase().trim()) { + // Load endpoints + case '1': + this.pause(); + this.loadEndpointsScreen.show({ resume: true }).then(() => { + this.show({ resume: true }); + }); + return; + + case '2': + this.pause(); + this.actionsResponderScreen.show({ resume: true }).then(() => { + this.show({ resume: true }); + }); + return; + case 'E': + this.hide(); + return; + } + + this.throwUnknownChoiceError(choice); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/action_responder.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/action_responder.ts new file mode 100644 index 000000000000..93816e06bb7f --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/action_responder.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { set } from 'lodash'; +import type { Client } from '@elastic/elasticsearch'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type { KbnClient } from '@kbn/test'; +import { BaseRunningService } from '../../common/base_running_service'; +import { + fetchEndpointActionList, + sendEndpointActionResponse, + sendFleetActionResponse, +} from './endpoint_response_actions'; +import type { ActionDetails } from '../../../../common/endpoint/types'; + +/** + * Base class for start/stopping background services + */ +export class ActionResponderService extends BaseRunningService { + private readonly delay: number; + + constructor( + esClient: Client, + kbnClient: KbnClient, + logger?: ToolingLog, + intervalMs?: number, + delay: number = 5_000 // 5s + ) { + super(esClient, kbnClient, logger, intervalMs); + this.delay = delay; + } + + protected async run(): Promise { + const { logger: log, kbnClient, esClient, delay } = this; + + let hasMore = true; + let nextPage = 1; + + try { + while (hasMore) { + const { data: actions } = await fetchEndpointActionList(kbnClient, { + page: nextPage++, + pageSize: 100, + }); + + if (actions.length === 0) { + hasMore = false; + return; + } + + for (const action of actions) { + if (action.isCompleted === false) { + if (Date.now() - new Date(action.startedAt).getTime() >= delay) { + log.verbose( + `${this.logPrefix}.run() [${new Date().toLocaleTimeString()}]: Responding to [${ + action.command + }] action [id: ${action.id}] agent: [${action.agents.join(', ')}]` + ); + + const tokens = parseCommentTokens(getActionComment(action)); + + log.verbose(`${this.logPrefix}.run() tokens found in action:`, tokens); + + const fleetResponse = await sendFleetActionResponse(esClient, action, { + // If an Endpoint state token was found, then force the Fleet response to `success` + // so that we can actually generate an endpoint response below. + state: tokens.state ? 'success' : tokens.fleet.state, + }); + + // If not a fleet response error, then also sent the Endpoint Response + if (!fleetResponse.error) { + await sendEndpointActionResponse(esClient, action, { state: tokens.state }); + } + } + } + } + } + } catch (e) { + log.error(`${this.logPrefix}.run() ${e.message}. Run with '--verbose' option to see more`); + log.verbose(e); + } + } +} + +interface CommentTokens { + state: 'success' | 'failure' | undefined; + fleet: { + state: 'success' | 'failure' | undefined; + }; +} + +const parseCommentTokens = (comment: string): CommentTokens => { + const response: CommentTokens = { + state: undefined, + fleet: { + state: undefined, + }, + }; + + if (comment) { + const findTokensRegExp = /(respond\.\S*=\S*)/gi; + let matches; + + while ((matches = findTokensRegExp.exec(comment)) !== null) { + const [key, value] = matches[0] + .toLowerCase() + .split('=') + .map((s) => s.trim()); + + set(response, key.split('.').slice(1), value); + } + } + return response; +}; + +const getActionComment = (action: ActionDetails): string => { + return action.comment ?? ''; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/agent_keep_alive.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/agent_keep_alive.ts new file mode 100644 index 000000000000..1d0a94df9e7a --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/agent_keep_alive.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { checkInFleetAgent } from '../../common/fleet_services'; +import { + fetchEndpointMetadataList, + sendEndpointMetadataUpdate, +} from '../../common/endpoint_metadata_services'; +import { BaseRunningService } from '../../common/base_running_service'; + +export class AgentKeepAliveService extends BaseRunningService { + protected async run(): Promise { + const { logger: log, kbnClient, esClient } = this; + + let hasMore = true; + let page = 0; + let errorFound = 0; + + try { + do { + const endpoints = await fetchEndpointMetadataList(kbnClient, { + page: page++, + pageSize: 100, + }); + + if (endpoints.data.length === 0) { + hasMore = false; + } else { + if (endpoints.page === 0) { + log.verbose( + `${this.logPrefix}.run() Number of endpoints to process: ${endpoints.total}` + ); + } + + for (const endpoint of endpoints.data) { + await Promise.all([ + checkInFleetAgent(esClient, endpoint.metadata.elastic.agent.id, { + log, + }).catch((err) => { + log.verbose(err); + errorFound++; + return Promise.resolve(); + }), + sendEndpointMetadataUpdate(esClient, endpoint.metadata.agent.id).catch((err) => { + log.verbose(err); + errorFound++; + return Promise.resolve(); + }), + ]); + } + } + } while (hasMore); + } catch (err) { + log.error( + `${this.logPrefix}.run() Error: ${err.message}. Use the '--verbose' option to see more.` + ); + + log.verbose(err); + } + + if (errorFound > 0) { + log.error( + `${this.logPrefix}.run() Error: Encountered ${errorFound} error(s). Use the '--verbose' option to see more.` + ); + } + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/emulator_run_context.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/emulator_run_context.ts new file mode 100644 index 000000000000..3c4978cd4447 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/emulator_run_context.ts @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import type { KbnClient } from '@kbn/test'; +import type { Client } from '@elastic/elasticsearch'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type { AgentEmulatorSettings } from '../types'; +import { SettingsStorage } from '../../common/settings_storage'; +import { AgentKeepAliveService } from './agent_keep_alive'; +import { ActionResponderService } from './action_responder'; +import { createRuntimeServices } from '../../common/stack_services'; + +export interface EmulatorRunContextConstructorOptions { + username: string; + password: string; + kibanaUrl: string; + elasticsearchUrl: string; + actionResponseDelay: number; + checkinInterval: number; + asSuperuser?: boolean; + log?: ToolingLog; +} + +export class EmulatorRunContext { + private esClient: Client | undefined = undefined; + private kbnClient: KbnClient | undefined = undefined; + private wasStarted: boolean = false; + private agentKeepAliveService: AgentKeepAliveService | undefined = undefined; + private actionResponderService: ActionResponderService | undefined = undefined; + + private readonly username: string; + private readonly password: string; + private readonly kibanaUrl: string; + private readonly elasticsearchUrl: string; + private readonly actionResponseDelay: number; + private readonly checkinInterval: number; + private readonly asSuperuser: boolean = false; + private log: ToolingLog | undefined = undefined; + private settings: SettingsStorage | undefined = undefined; + + constructor(options: EmulatorRunContextConstructorOptions) { + this.username = options.username; + this.password = options.password; + this.kibanaUrl = options.kibanaUrl; + this.elasticsearchUrl = options.elasticsearchUrl; + this.actionResponseDelay = options.actionResponseDelay; + this.checkinInterval = options.checkinInterval; + this.asSuperuser = options.asSuperuser ?? false; + this.log = options.log; + } + + async start() { + if (this.wasStarted) { + return; + } + + this.settings = new SettingsStorage('endpoint_agent_emulator.json', { + defaultSettings: { + version: 1, + endpointLoader: { + count: 2, + }, + }, + }); + + const { esClient, kbnClient, log } = await createRuntimeServices({ + kibanaUrl: this.kibanaUrl, + elasticsearchUrl: this.elasticsearchUrl, + username: this.username, + password: this.password, + asSuperuser: this.asSuperuser, + log: this.log, + }); + + this.esClient = esClient; + this.kbnClient = kbnClient; + this.log = log; + + this.agentKeepAliveService = new AgentKeepAliveService( + esClient, + kbnClient, + log, + this.checkinInterval + ); + this.agentKeepAliveService.start(); + + this.actionResponderService = new ActionResponderService( + esClient, + kbnClient, + log, + 5_000, // Check for actions every 5s + this.actionResponseDelay + ); + this.actionResponderService.start(); + + this.wasStarted = true; + } + + async stop(): Promise { + this.getAgentKeepAliveService().stop(); + this.getActionResponderService().stop(); + this.wasStarted = false; + } + + protected ensureStarted() { + if (!this.wasStarted) { + throw new Error('RunContext instance has not been `.start()`ed!'); + } + } + + public get whileRunning(): Promise { + this.ensureStarted(); + + return Promise.all([ + this.getActionResponderService().whileRunning, + this.getAgentKeepAliveService().whileRunning, + ]).then(() => {}); + } + + getSettingsService(): SettingsStorage { + this.ensureStarted(); + return this.settings!; + } + + getActionResponderService(): ActionResponderService { + this.ensureStarted(); + return this.actionResponderService!; + } + + getAgentKeepAliveService(): AgentKeepAliveService { + this.ensureStarted(); + return this.agentKeepAliveService!; + } + + getEsClient(): Client { + this.ensureStarted(); + return this.esClient!; + } + + getKbnClient(): KbnClient { + this.ensureStarted(); + return this.kbnClient!; + } + + getLogger(): ToolingLog { + this.ensureStarted(); + return this.log!; + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_loader.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_loader.ts new file mode 100644 index 000000000000..ccf252c4d551 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_loader.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable max-classes-per-file */ + +import type { Client } from '@elastic/elasticsearch'; +import type { KbnClient } from '@kbn/test'; +import pMap from 'p-map'; +import type { CreatePackagePolicyResponse } from '@kbn/fleet-plugin/common'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type seedrandom from 'seedrandom'; +import { kibanaPackageJson } from '@kbn/utils'; +import { indexAlerts } from '../../../../common/endpoint/data_loaders/index_alerts'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { fetchEndpointMetadataList } from '../../common/endpoint_metadata_services'; +import { indexEndpointHostDocs } from '../../../../common/endpoint/data_loaders/index_endpoint_hosts'; +import { setupFleetForEndpoint } from '../../../../common/endpoint/data_loaders/setup_fleet_for_endpoint'; +import { enableFleetServerIfNecessary } from '../../../../common/endpoint/data_loaders/index_fleet_server'; +import { fetchEndpointPackageInfo } from '../../common/fleet_services'; +import { METADATA_DATASTREAM } from '../../../../common/endpoint/constants'; +import { EndpointMetadataGenerator } from '../../../../common/endpoint/data_generators/endpoint_metadata_generator'; +import { ENDPOINT_ALERTS_INDEX, ENDPOINT_EVENTS_INDEX } from '../../common/constants'; + +let WAS_FLEET_SETUP_DONE = false; + +const CurrentKibanaVersionDocGenerator = class extends EndpointDocGenerator { + constructor(seedValue: string | seedrandom.prng) { + const MetadataGenerator = class extends EndpointMetadataGenerator { + protected randomVersion(): string { + return kibanaPackageJson.version; + } + }; + + super(seedValue, MetadataGenerator); + } +}; + +export const loadEndpointsIfNoneExist = async ( + esClient: Client, + kbnClient: KbnClient, + log?: ToolingLog, + count: number = 2 +): Promise => { + if (!count || (await fetchEndpointMetadataList(kbnClient, { pageSize: 1 })).total > 0) { + if (log) { + log.verbose('loadEndpointsIfNoneExist(): Endpoints exist. Exiting (nothing was done)'); + } + + return; + } + + return loadEndpoints({ + count: 2, + esClient, + kbnClient, + log, + }); +}; + +interface LoadEndpointsProgress { + percent: number; + total: number; + created: number; +} + +interface LoadEndpointsOptions { + esClient: Client; + kbnClient: KbnClient; + count?: number; + log?: ToolingLog; + onProgress?: (percentDone: LoadEndpointsProgress) => void; + DocGeneratorClass?: typeof EndpointDocGenerator; +} + +/** + * Loads endpoints, including the corresponding fleet agent, into Kibana along with events and alerts + * + * @param count + * @param esClient + * @param kbnClient + * @param log + * @param onProgress + * @param DocGeneratorClass + */ +export const loadEndpoints = async ({ + esClient, + kbnClient, + log, + onProgress, + count = 2, + DocGeneratorClass = CurrentKibanaVersionDocGenerator, +}: LoadEndpointsOptions): Promise => { + if (log) { + log.verbose(`loadEndpoints(): Loading ${count} endpoints...`); + } + + if (!WAS_FLEET_SETUP_DONE) { + await setupFleetForEndpoint(kbnClient); + await enableFleetServerIfNecessary(esClient); + // eslint-disable-next-line require-atomic-updates + WAS_FLEET_SETUP_DONE = true; + } + + const endpointPackage = await fetchEndpointPackageInfo(kbnClient); + const realPolicies: Record = {}; + + let progress: LoadEndpointsProgress = { + total: count, + created: 0, + percent: 0, + }; + + const updateProgress = () => { + const created = progress.created + 1; + progress = { + ...progress, + created, + percent: Math.ceil((created / count) * 100), + }; + + if (onProgress) { + onProgress(progress); + } + }; + + await pMap( + Array.from({ length: count }), + async () => { + const endpointGenerator = new DocGeneratorClass(); + + await indexEndpointHostDocs({ + numDocs: 1, + client: esClient, + kbnClient, + realPolicies, + epmEndpointPackage: endpointPackage, + generator: endpointGenerator, + enrollFleet: true, + metadataIndex: METADATA_DATASTREAM, + policyResponseIndex: 'metrics-endpoint.policy-default', + }); + + await indexAlerts({ + client: esClient, + generator: endpointGenerator, + eventIndex: ENDPOINT_EVENTS_INDEX, + alertIndex: ENDPOINT_ALERTS_INDEX, + numAlerts: 1, + options: { + ancestors: 3, + generations: 3, + children: 3, + relatedEvents: 5, + relatedAlerts: 5, + percentWithRelated: 30, + percentTerminated: 30, + alwaysGenMaxChildrenPerNode: false, + ancestryArraySize: 2, + eventsDataStream: { + type: 'logs', + dataset: 'endpoint.events.process', + namespace: 'default', + }, + alertsDataStream: { type: 'logs', dataset: 'endpoint.alerts', namespace: 'default' }, + }, + }); + + updateProgress(); + }, + { + concurrency: 4, + } + ); + + if (log) { + log.verbose(`loadEndpoints(): ${count} endpoint(s) successfully loaded`); + } +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/utils.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts similarity index 77% rename from x-pack/plugins/security_solution/scripts/endpoint/action_responder/utils.ts rename to x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts index 57c8ecf7f724..8a1aa4050d07 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/utils.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts @@ -8,15 +8,16 @@ import type { KbnClient } from '@kbn/test'; import type { Client } from '@elastic/elasticsearch'; import { AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; -import type { UploadedFile } from '../../../common/endpoint/types/file_storage'; -import { sendEndpointMetadataUpdate } from '../common/endpoint_metadata_services'; -import { FleetActionGenerator } from '../../../common/endpoint/data_generators/fleet_action_generator'; +import type { UploadedFile } from '../../../../common/endpoint/types/file_storage'; +import { checkInFleetAgent } from '../../common/fleet_services'; +import { sendEndpointMetadataUpdate } from '../../common/endpoint_metadata_services'; +import { FleetActionGenerator } from '../../../../common/endpoint/data_generators/fleet_action_generator'; import { ENDPOINT_ACTION_RESPONSES_INDEX, ENDPOINTS_ACTION_LIST_ROUTE, FILE_STORAGE_DATA_INDEX, FILE_STORAGE_METADATA_INDEX, -} from '../../../common/endpoint/constants'; +} from '../../../../common/endpoint/constants'; import type { ActionDetails, ActionListApiResponse, @@ -26,9 +27,9 @@ import type { GetProcessesActionOutputContent, ResponseActionGetFileOutputContent, ResponseActionGetFileParameters, -} from '../../../common/endpoint/types'; -import type { EndpointActionListRequestQuery } from '../../../common/endpoint/schema/actions'; -import { EndpointActionGenerator } from '../../../common/endpoint/data_generators/endpoint_action_generator'; +} from '../../../../common/endpoint/types'; +import type { EndpointActionListRequestQuery } from '../../../../common/endpoint/schema/actions'; +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } }; @@ -42,13 +43,33 @@ export const fetchEndpointActionList = async ( kbn: KbnClient, options: EndpointActionListRequestQuery = {} ): Promise => { - return ( - await kbn.request({ - method: 'GET', - path: ENDPOINTS_ACTION_LIST_ROUTE, - query: options, - }) - ).data; + try { + return ( + await kbn.request({ + method: 'GET', + path: ENDPOINTS_ACTION_LIST_ROUTE, + query: options, + }) + ).data; + } catch (error) { + // FIXME: remove once the Action List API is fixed (task #5221) + if (error?.response?.status === 404) { + return { + data: [], + total: 0, + page: 1, + pageSize: 10, + startDate: undefined, + elasticAgentIds: undefined, + endDate: undefined, + userIds: undefined, + commands: undefined, + statuses: undefined, + }; + } + + throw error; + } }; export const sendFleetActionResponse = async ( @@ -125,26 +146,34 @@ export const sendEndpointActionResponse = async ( // For isolate, If the response is not an error, then also send a metadata update if (action.command === 'isolate' && !endpointResponse.error) { for (const agentId of action.agents) { - await sendEndpointMetadataUpdate(esClient, agentId, { - Endpoint: { - state: { - isolation: true, + await Promise.all([ + sendEndpointMetadataUpdate(esClient, agentId, { + Endpoint: { + state: { + isolation: true, + }, }, - }, - }); + }), + + checkInFleetAgent(esClient, agentId), + ]); } } // For UnIsolate, if response is not an Error, then also send metadata update if (action.command === 'unisolate' && !endpointResponse.error) { for (const agentId of action.agents) { - await sendEndpointMetadataUpdate(esClient, agentId, { - Endpoint: { - state: { - isolation: false, + await Promise.all([ + sendEndpointMetadataUpdate(esClient, agentId, { + Endpoint: { + state: { + isolation: false, + }, }, - }, - }); + }), + + checkInFleetAgent(esClient, agentId), + ]); } } diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/types.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/types.ts new file mode 100644 index 000000000000..5713f28c4916 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface AgentEmulatorSettings { + /** Version of the settings. Can be used in the future if we need to do settings migration */ + version: number; + endpointLoader: LoadEndpointsConfig; +} + +export interface LoadEndpointsConfig { + count: number; +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/base_running_service.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/base_running_service.ts new file mode 100644 index 000000000000..e195d7e1f60c --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/base_running_service.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ToolingLog } from '@kbn/tooling-log'; +import type { KbnClient } from '@kbn/test'; +import type { Client } from '@elastic/elasticsearch'; +import moment from 'moment'; + +/** + * A base class for creating a service that runs on a interval + */ +export class BaseRunningService { + private nextRunId: ReturnType | undefined; + private markRunComplete: (() => void) | undefined; + + protected wasStarted = false; + + /** Promise that remains pending while the service is running */ + public whileRunning: Promise = Promise.resolve(); + + protected readonly logPrefix: string; + + constructor( + protected readonly esClient: Client, + protected readonly kbnClient: KbnClient, + protected readonly logger: ToolingLog = new ToolingLog(), + protected readonly intervalMs: number = 30_000 // 30s + ) { + this.logPrefix = this.constructor.name ?? 'BaseRunningService'; + this.logger.verbose(`${this.logPrefix} run interval: [ ${this.intervalMs} ]`); + } + + public get isRunning(): boolean { + return this.wasStarted; + } + + start() { + if (this.wasStarted) { + return; + } + + this.wasStarted = true; + this.whileRunning = new Promise((resolve) => { + this.markRunComplete = () => resolve(); + }); + + this.logger.verbose(`${this.logPrefix}: started at ${new Date().toISOString()}`); + + this.run().finally(() => { + this.scheduleNextRun(); + }); + } + + stop() { + if (this.wasStarted) { + this.clearNextRun(); + this.wasStarted = false; + + if (this.markRunComplete) { + this.markRunComplete(); + this.markRunComplete = undefined; + } + + this.logger.verbose(`${this.logPrefix}: stopped at ${new Date().toISOString()}`); + } + } + + protected scheduleNextRun() { + this.clearNextRun(); + + if (this.wasStarted) { + this.nextRunId = setTimeout(async () => { + const startedAt = new Date(); + + await this.run(); + + const endedAt = new Date(); + + this.logger.verbose( + `${this.logPrefix}.run(): completed in ${moment + .duration(moment(endedAt).diff(startedAt, 'seconds')) + .as('seconds')}s` + ); + this.logger.indent(4, () => { + this.logger.verbose(`started at: ${startedAt.toISOString()}`); + this.logger.verbose(`ended at: ${startedAt.toISOString()}`); + }); + + this.scheduleNextRun(); + }, this.intervalMs); + } + } + + protected clearNextRun() { + if (this.nextRunId) { + clearTimeout(this.nextRunId); + this.nextRunId = undefined; + } + } + + protected async run(): Promise { + throw new Error(`${this.logPrefix}.run() not implemented!`); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/constants.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/constants.ts new file mode 100644 index 000000000000..1d2b3d5f4778 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const HORIZONTAL_LINE = '-'.repeat(80); + +export const ENDPOINT_EVENTS_INDEX = 'logs-endpoint.events.process-default'; + +export const ENDPOINT_ALERTS_INDEX = 'logs-endpoint.alerts-default'; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts index 2a51c57de8bc..a1f21b80567d 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts @@ -6,31 +6,62 @@ */ import type { Client } from '@elastic/elasticsearch'; +import type { KbnClient } from '@kbn/test'; import type { WriteResponseBase } from '@elastic/elasticsearch/lib/api/types'; import { clone, merge } from 'lodash'; import type { DeepPartial } from 'utility-types'; -import { METADATA_DATASTREAM } from '../../../common/endpoint/constants'; -import type { HostMetadata } from '../../../common/endpoint/types'; +import type { GetMetadataListRequestQuery } from '../../../common/endpoint/schema/metadata'; +import { resolvePathVariables } from '../../../public/common/utils/resolve_path_variables'; +import { + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, + METADATA_DATASTREAM, +} from '../../../common/endpoint/constants'; +import type { HostInfo, HostMetadata, MetadataListResponse } from '../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../common/endpoint/generate_data'; -import { checkInFleetAgent } from './fleet_services'; const endpointGenerator = new EndpointDocGenerator(); +export const fetchEndpointMetadata = async ( + kbnClient: KbnClient, + agentId: string +): Promise => { + return ( + await kbnClient.request({ + method: 'GET', + path: resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: agentId }), + }) + ).data; +}; + +export const fetchEndpointMetadataList = async ( + kbnClient: KbnClient, + { page = 0, pageSize = 100, ...otherOptions }: Partial = {} +): Promise => { + return ( + await kbnClient.request({ + method: 'GET', + path: HOST_METADATA_LIST_ROUTE, + query: { + page, + pageSize, + ...otherOptions, + }, + }) + ).data; +}; + export const sendEndpointMetadataUpdate = async ( esClient: Client, agentId: string, - overrides: DeepPartial = {}, - { checkInAgent = true }: Partial<{ checkInAgent: boolean }> = {} + overrides: DeepPartial = {} ): Promise => { const lastStreamedDoc = await fetchLastStreamedEndpointUpdate(esClient, agentId); if (!lastStreamedDoc) { - throw new Error(`An endpoint with agent.id of [${agentId}] not found!`); - } - - if (checkInAgent) { - // Trigger an agent checkin and just let it run - checkInFleetAgent(esClient, agentId); + throw new Error( + `An endpoint with agent.id of [${agentId}] not found! [sendEndpointMetadataUpdate()]` + ); } const generatedHostMetadataDoc = clone(endpointGenerator.generateHostMetadata()); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts index 3d7d3c5a0317..c94b66d68a5d 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts @@ -5,23 +5,81 @@ * 2.0. */ -import type { Client } from '@elastic/elasticsearch'; -import { AGENTS_INDEX } from '@kbn/fleet-plugin/common'; +import type { Client, estypes } from '@elastic/elasticsearch'; +import { AGENTS_INDEX, EPM_API_ROUTES } from '@kbn/fleet-plugin/common'; +import type { AgentStatus, GetPackagesResponse } from '@kbn/fleet-plugin/common'; +import { pick } from 'lodash'; +import { ToolingLog } from '@kbn/tooling-log'; +import type { AxiosResponse } from 'axios'; +import type { KbnClient } from '@kbn/test'; +import { FleetAgentGenerator } from '../../../common/endpoint/data_generators/fleet_agent_generator'; -export const checkInFleetAgent = async (esClient: Client, agentId: string) => { - const checkinNow = new Date().toISOString(); +const fleetGenerator = new FleetAgentGenerator(); - await esClient.update({ +export const checkInFleetAgent = async ( + esClient: Client, + agentId: string, + { + agentStatus = 'online', + log = new ToolingLog(), + }: Partial<{ + /** The agent status to be sent. If set to `random`, then one will be randomly generated */ + agentStatus: AgentStatus | 'random'; + log: ToolingLog; + }> = {} +): Promise => { + const fleetAgentStatus = + agentStatus === 'random' ? fleetGenerator.randomAgentStatus() : agentStatus; + + const update = pick(fleetGenerator.generateEsHitWithStatus(fleetAgentStatus)._source, [ + 'last_checkin_status', + 'last_checkin', + 'active', + 'unenrollment_started_at', + 'unenrolled_at', + 'upgrade_started_at', + 'upgraded_at', + ]); + + // WORKAROUND: Endpoint API will exclude metadata for any fleet agent whose status is `inactive`, + // which means once we update the Fleet agent with that status, the metadata api will no longer + // return the endpoint host info.'s. So - we avoid that here. + update.active = true; + + // Ensure any `undefined` value is set to `null` for the update + Object.entries(update).forEach(([key, value]) => { + if (value === undefined) { + // @ts-expect-error TS7053 Element implicitly has an 'any' type + update[key] = null; + } + }); + + log.verbose(`update to fleet agent [${agentId}][${agentStatus} / ${fleetAgentStatus}]: `, update); + + return esClient.update({ index: AGENTS_INDEX, id: agentId, refresh: 'wait_for', retry_on_conflict: 5, body: { - doc: { - active: true, - last_checkin: checkinNow, - updated_at: checkinNow, - }, + doc: update, }, }); }; + +export const fetchEndpointPackageInfo = async ( + kbnClient: KbnClient +): Promise => { + const endpointPackage = ( + (await kbnClient.request({ + path: `${EPM_API_ROUTES.LIST_PATTERN}?category=security`, + method: 'GET', + })) as AxiosResponse + ).data.items.find((epmPackage) => epmPackage.name === 'endpoint'); + + if (!endpointPackage) { + throw new Error('EPM Endpoint package was not found!'); + } + + return endpointPackage; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/choice_menu_formatter.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/choice_menu_formatter.ts new file mode 100644 index 000000000000..a1348ad719b0 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/choice_menu_formatter.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { green } from 'chalk'; +import { isChoice } from './type_gards'; +import type { Choice } from './types'; +import { DataFormatter } from './data_formatter'; + +type ChoiceMenuFormatterItems = string[] | Choice[]; + +interface ChoiceMenuFormatterOptions { + layout: 'vertical' | 'horizontal'; +} + +const getDefaultOptions = (): ChoiceMenuFormatterOptions => { + return { + layout: 'vertical', + }; +}; + +/** + * Formatter for displaying lists of choices + */ +export class ChoiceMenuFormatter extends DataFormatter { + private readonly outputContent: string; + + constructor( + private readonly choiceList: ChoiceMenuFormatterItems, + private readonly options: ChoiceMenuFormatterOptions = getDefaultOptions() + ) { + super(); + + const list = this.buildList(); + + this.outputContent = `${list.join(this.options.layout === 'horizontal' ? ' ' : '\n')}`; + } + + protected getOutput(): string { + return this.outputContent; + } + + private buildList(): string[] { + return this.choiceList.map((choice, index) => { + let key: string = `${index + 1}`; + let title: string = ''; + + if (isChoice(choice)) { + key = choice.key; + title = choice.title; + } else { + title = choice; + } + + return green(`[${key}] `) + title; + }); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/column_layout_formatter.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/column_layout_formatter.ts new file mode 100644 index 000000000000..ab07ddd53553 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/column_layout_formatter.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import stripAnsi from 'strip-ansi'; +// eslint-disable-next-line import/no-extraneous-dependencies +import ansiRegex from 'ansi-regex'; // its a dependency of `strip-ansi` so it should be fine +import { blue } from 'chalk'; +import { DataFormatter } from './data_formatter'; +import { SCREEN_ROW_MAX_WIDTH } from './constants'; + +interface ColumnLayoutFormatterOptions { + /** + * The width (percentage) for each of the columns. Example: 80 (for 80%). + */ + widths?: number[]; + + /** The column separator */ + separator?: string; + + /** The max length for each screen row. Defaults to the overall screen width */ + rowLength?: number; +} + +export class ColumnLayoutFormatter extends DataFormatter { + private readonly defaultSeparator = ` ${blue('\u2506')} `; + + constructor( + private readonly columns: Array, + private readonly options: ColumnLayoutFormatterOptions = {} + ) { + super(); + } + + protected getOutput(): string { + const colSeparator = this.options.separator ?? this.defaultSeparator; + let rowCount = 0; + const columnData: string[][] = this.columns.map((item) => { + const itemOutput = (typeof item === 'string' ? item : item.output).split('\n'); + + rowCount = Math.max(rowCount, itemOutput.length); + + return itemOutput; + }); + const columnSizes = this.calculateColumnSizes(); + let output = ''; + + let row = 0; + while (row < rowCount) { + const rowIndex = row++; + + output += `${columnData + .map((columnDataRows, colIndex) => { + return this.fillColumnToWidth(columnDataRows[rowIndex] ?? '', columnSizes[colIndex]); + }) + .join(colSeparator)}`; + + if (row !== rowCount) { + output += '\n'; + } + } + + return output; + } + + private calculateColumnSizes(): number[] { + const maxWidth = this.options.rowLength ?? SCREEN_ROW_MAX_WIDTH; + const widths = this.options.widths ?? []; + const defaultWidthPrct = Math.floor(100 / this.columns.length); + + return this.columns.map((_, colIndex) => { + return Math.floor(maxWidth * ((widths[colIndex] ?? defaultWidthPrct) / 100)); + }); + } + + private fillColumnToWidth(colData: string, width: number) { + const countOfControlChar = (colData.match(ansiRegex()) || []).length; + const colDataNoControlChar = stripAnsi(colData); + const colDataFilled = colDataNoControlChar.padEnd(width).substring(0, width); + const fillCount = colDataFilled.length - colDataNoControlChar.length - countOfControlChar; + + return colData + (fillCount > 0 ? ' '.repeat(fillCount) : ''); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/common_choices.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/common_choices.ts new file mode 100644 index 000000000000..12cd636e07d8 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/common_choices.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Choice } from './types'; + +/** + * The Quit choice definition + */ +export const QuitChoice: Choice = { + key: 'Q', + title: 'Quit', +} as const; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/constants.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/constants.ts new file mode 100644 index 000000000000..a1c3eab88314 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/constants.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HORIZONTAL_LINE } from '../constants'; + +export const SCREEN_ROW_MAX_WIDTH = HORIZONTAL_LINE.length; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/data_formatter.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/data_formatter.ts new file mode 100644 index 000000000000..6f02d21d5612 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/data_formatter.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Base class for screen data formatters + */ +export class DataFormatter { + /** + * Must be defiened by Subclasses + * @protected + */ + protected getOutput(): string { + throw new Error(`${this.constructor.name}.getOutput() not implemented!`); + } + + public get output(): string { + return this.getOutput(); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/index.ts new file mode 100644 index 000000000000..29f289b4d241 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ScreenBaseClass } from './screen_base_class'; +export { ChoiceMenuFormatter } from './choice_menu_formatter'; +export { DataFormatter } from './data_formatter'; +export * from './types'; +export * from './type_gards'; +export * from './common_choices'; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/progress_formatter.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/progress_formatter.ts new file mode 100644 index 000000000000..c5a09d02b472 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/progress_formatter.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { green } from 'chalk'; +import { SCREEN_ROW_MAX_WIDTH } from './constants'; +import { DataFormatter } from './data_formatter'; + +const MAX_WIDTH = SCREEN_ROW_MAX_WIDTH - 14; + +export class ProgressFormatter extends DataFormatter { + private percentDone: number = 0; + + public setProgress(percentDone: number) { + this.percentDone = percentDone; + } + + protected getOutput(): string { + const prctDone = Math.min(100, this.percentDone); + const repeatValue = Math.ceil(MAX_WIDTH * (prctDone / 100)); + const progressPrct = `${prctDone}%`; + + return `[ ${'='.repeat(repeatValue).padEnd(MAX_WIDTH)} ] ${ + prctDone === 100 ? green(progressPrct) : progressPrct + }`; + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/screen_base_class.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/screen_base_class.ts new file mode 100644 index 000000000000..32657bac374f --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/screen_base_class.ts @@ -0,0 +1,364 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable max-classes-per-file */ + +import type { WriteStream as TtyWriteStream } from 'tty'; +import { stdin, stdout } from 'node:process'; +import * as readline from 'node:readline'; +import { blue, green, red, bold, cyan } from 'chalk'; +import type { QuestionCollection } from 'inquirer'; +import inquirer from 'inquirer'; +import { QuitChoice } from './common_choices'; +import type { Choice } from './types'; +import { ChoiceMenuFormatter } from './choice_menu_formatter'; +import { DataFormatter } from './data_formatter'; +import { HORIZONTAL_LINE } from '../constants'; +import { SCREEN_ROW_MAX_WIDTH } from './constants'; + +const CONTENT_60_PERCENT = Math.floor(SCREEN_ROW_MAX_WIDTH * 0.6); +const CONTENT_40_PERCENT = Math.floor(SCREEN_ROW_MAX_WIDTH * 0.4); + +/** + * Base class for creating a CLI screen. + * + * @example + * + * // Screen definition + * export class FooScreen extends ScreenBaseClass { + * protected body() { + * return `this is a test screen` + * } + * + * protected onEnterChoice(choice) { + * if (choice.toUpperCase() === 'Q') { + * this.hide(); + * return; + * } + * + * this.throwUnknownChoiceError(choice); + * } + * } + * + * // Using the screen + * await new FooScreen().show() + */ +export class ScreenBaseClass { + private readonly ttyOut: TtyWriteStream = stdout; + private readlineInstance: readline.Interface | undefined = undefined; + private showPromise: Promise | undefined = undefined; + private endSession: (() => void) | undefined = undefined; + private screenRenderInfo: RenderedScreen | undefined; + private isPaused: boolean = false; + private isHidden: boolean = true; + private autoClearMessageId: undefined | NodeJS.Timeout = undefined; + + /** + * Provides content for the header of the screen. + * + * @param title Displayed on the left side of the header area + * @param subTitle Displayed to the right of the header + * @protected + */ + protected header(title: string = '', subTitle: string = ''): string | DataFormatter { + const paddedTitle = title ? ` ${title}`.padEnd(CONTENT_60_PERCENT) : ''; + const paddedSubTitle = subTitle ? `| ${`${subTitle} `.padStart(CONTENT_40_PERCENT - 2)}` : ''; + + return title || subTitle + ? `${blue(HORIZONTAL_LINE)}\n${blue(bold(paddedTitle))}${ + subTitle ? `${cyan(paddedSubTitle)}` : '' + }\n${blue(HORIZONTAL_LINE)}\n` + : `${blue(HORIZONTAL_LINE)}\n`; + } + + /** + * Provides content for the footer of the screen + * + * @param choices Optional list of choices for display above the footer. + * @protected + */ + protected footer(choices: Choice[] = [QuitChoice]): string | DataFormatter { + const displayChoices = + choices && choices.length + ? `${this.leftPad(new ChoiceMenuFormatter(choices, { layout: 'horizontal' }).output)}\n` + : ''; + + return ` +${displayChoices}${blue(HORIZONTAL_LINE)}`; + } + + /** + * Content for the Body area of the screen + * + * @protected + */ + protected body(): string | DataFormatter { + return '\n\n(This screen has no content)\n\n'; + } + + /** + * Should be defined by the subclass to handle user selections. If the user's + * selection is invalid, this method should `throw` and `Error` - the message + * will be displayed in the screen and the user will be asked for input again. + * + * @param choice + * @protected + */ + protected onEnterChoice(choice: string) { + if (choice.toUpperCase() === 'Q') { + this.hide(); + return; + } + + throw new Error(`${this.constructor.name}.onEnterChoice() not implemented!`); + } + + /** + * Throw an error indicating invalid choice was made by the user. + * @param choice + * @protected + */ + protected throwUnknownChoiceError(choice: string): never { + throw new Error(`Unknown choice: ${choice}`); + } + + protected getOutputContent(item: string | DataFormatter): string { + return item instanceof DataFormatter ? item.output : item; + } + + private closeReadline() { + if (this.readlineInstance) { + this.readlineInstance.close(); + this.readlineInstance = undefined; + } + } + + protected leftPad(content: string, padWith: string = ' ') { + return content + .split('\n') + .map((contentLine) => { + if (!contentLine.startsWith(padWith)) { + return padWith + contentLine; + } + return contentLine; + }) + .join('\n'); + } + + public showMessage( + message: string, + color: 'blue' | 'red' | 'green' = 'blue', + autoClear: boolean = false + ) { + const { screenRenderInfo, ttyOut } = this; + + if (this.autoClearMessageId) { + clearTimeout(this.autoClearMessageId); + this.autoClearMessageId = undefined; + } + + if (screenRenderInfo) { + ttyOut.cursorTo(0, screenRenderInfo.statusPos); + ttyOut.clearLine(0); + + let coloredMessage = message; + + switch (color) { + case 'green': + coloredMessage = green(`\u2713 ${message}`); + break; + case 'red': + coloredMessage = red(`\u24e7 ${message}`); + break; + + case 'blue': + coloredMessage = blue(`\u24d8 ${message}`); + break; + } + + ttyOut.write(` ${coloredMessage}`); + + if (autoClear) { + this.autoClearMessageId = setTimeout(() => { + this.showMessage(''); + }, 4000); + } + } + } + + private clearPromptOutput() { + const { ttyOut, screenRenderInfo } = this; + + if (screenRenderInfo) { + ttyOut.cursorTo(0, screenRenderInfo.promptPos ?? 0); + ttyOut.clearScreenDown(); + } + } + + private async askForChoice(prompt?: string): Promise { + this.closeReadline(); + this.clearPromptOutput(); + + return new Promise((resolve) => { + const rl = readline.createInterface({ input: stdin, output: stdout }); + this.readlineInstance = rl; + + // TODO:PT experiment with using `rl.prompt()` instead of `question()` and possibly only initialize `rl` once + + rl.question(green(prompt ?? 'Enter choice: '), (selection) => { + if (this.isPaused || this.isHidden) { + return; + } + + if (this.readlineInstance === rl) { + this.clearPromptOutput(); + this.closeReadline(); + + try { + this.onEnterChoice(selection); + } catch (error) { + this.showMessage(error.message, 'red'); + + resolve(this.askForChoice(prompt)); + + return; + } + + resolve(); + } + }); + }); + } + + private clearScreen() { + this.ttyOut.cursorTo(0, 0); + this.ttyOut.clearScreenDown(); + } + + /** + * Renders (or re-renders) the screen. Can be called multiple times + * + * @param prompt + */ + public reRender(prompt?: string) { + if (this.isHidden || this.isPaused) { + return; + } + + const { ttyOut } = this; + const headerContent = this.header(); + const bodyContent = this.body(); + const footerContent = this.footer(); + + const screenRenderInfo = new RenderedScreen( + this.getOutputContent(headerContent) + + this.leftPad(this.getOutputContent(bodyContent)) + + this.getOutputContent(footerContent) + ); + this.screenRenderInfo = screenRenderInfo; + + this.clearScreen(); + + ttyOut.write(screenRenderInfo.output); + + this.askForChoice(prompt); + } + + /** + * Will display the screen and return a promise that is resolved once the screen is hidden. + * + * @param prompt + * @param resume + */ + public show({ + prompt, + resume, + }: Partial<{ prompt: string; resume: boolean }> = {}): Promise { + if (resume) { + this.isPaused = false; + } + + if (this.isPaused) { + return Promise.resolve(undefined); + } + + this.isHidden = false; + this.reRender(prompt); + + // `show()` can be called multiple times, so only create the `showPromise` if one is not already present + if (!this.showPromise) { + this.showPromise = new Promise((resolve) => { + this.endSession = () => resolve(); + }); + } + + return this.showPromise; + } + + /** + * Will hide the screen and fulfill the promise returned by `.show()` + */ + public hide() { + this.closeReadline(); + this.clearScreen(); + this.screenRenderInfo = undefined; + this.isHidden = true; + this.isPaused = false; + + if (this.endSession) { + this.endSession(); + this.showPromise = undefined; + this.endSession = undefined; + } + } + + public pause() { + this.isPaused = true; + this.closeReadline(); + } + + public async prompt({ + questions, + answers = {}, + title = blue('Settings:'), + }: { + questions: QuestionCollection; + answers?: Partial; + title?: string; + }): Promise { + if (this.isPaused || this.isHidden) { + return answers as TAnswers; + } + + const screenRenderInfo = new RenderedScreen(this.getOutputContent(this.header())); + + this.screenRenderInfo = screenRenderInfo; + this.clearScreen(); + this.ttyOut.write(`${screenRenderInfo.output}${title ? `${this.leftPad(title)}\n` : ''}`); + + const ask = inquirer.createPromptModule(); + const newAnswers = await ask(questions, answers); + + return newAnswers; + } +} + +class RenderedScreen { + public statusPos: number = -1; + public promptPos: number = -1; + public statusMessage: string | undefined = undefined; + + constructor(private readonly screenOutput: string) { + const outputBottomPos = screenOutput.split('\n').length - 1; + this.statusPos = outputBottomPos + 1; + this.promptPos = this.statusPos + 1; + } + + public get output(): string { + return `${this.screenOutput}\n${this.statusMessage ?? ' '}\n`; + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/type_gards.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/type_gards.ts new file mode 100644 index 000000000000..e9fd23d56293 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/type_gards.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Choice } from './types'; + +/** + * Type guard that checks if a item is a `Choice` + * + * @param item + */ +export const isChoice = (item: string | object): item is Choice => { + return 'string' !== typeof item && 'key' in item && 'title' in item; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/types.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/types.ts new file mode 100644 index 000000000000..815403528dca --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * An item representing a choice/item to be shown on a screen + */ +export interface Choice { + /** The keyboard key (or combination of keys) that the user will enter to select this choice */ + key: string; + /** The title of the choice */ + title: string; +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/settings_storage.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/settings_storage.ts new file mode 100644 index 000000000000..d68da4bfc92b --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/settings_storage.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { homedir } from 'os'; +import { join } from 'path'; +import { mkdir, writeFile, readFile, unlink } from 'fs/promises'; +import { existsSync } from 'fs'; + +interface SettingStorageOptions { + /** The default directory where settings will be saved. Defaults to `~/.kibanaSecuritySolutionCliTools` */ + directory?: string; + + /** The default settings object (used if file does not exist yet) */ + defaultSettings?: TSettingsDef; +} + +/** + * A generic service for persisting settings. By default, all settings are saved to a directory + * under `~/.kibanaSecuritySolutionCliTools` + */ +export class SettingsStorage { + private options: Required>; + private readonly settingsFileFullPath: string; + private dirExists: boolean = false; + + constructor(fileName: string, options: SettingStorageOptions = {}) { + const { + directory = join(homedir(), '.kibanaSecuritySolutionCliTools'), + defaultSettings = {} as TSettingsDef, + } = options; + + this.options = { + directory, + defaultSettings, + }; + + this.settingsFileFullPath = join(this.options.directory, fileName); + } + + private async ensureExists(): Promise { + if (!this.dirExists) { + await mkdir(this.options.directory, { recursive: true }); + this.dirExists = true; + + if (!existsSync(this.settingsFileFullPath)) { + await this.save(this.options.defaultSettings); + } + } + } + + /** Retrieve the content of the settings file */ + public async get(): Promise { + await this.ensureExists(); + const fileContent = await readFile(this.settingsFileFullPath); + return JSON.parse(fileContent.toString()) as TSettingsDef; + } + + /** Save a new version of the settings to disk */ + public async save(newSettings: TSettingsDef): Promise { + // FIXME: Enhance this method so that Partial `newSettings` can be provided and they are merged into the existing set. + await this.ensureExists(); + await writeFile(this.settingsFileFullPath, JSON.stringify(newSettings, null, 2)); + } + + /** Deletes the settings file from disk */ + public async delete(): Promise { + await this.ensureExists(); + await unlink(this.settingsFileFullPath); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/endpoint_action_responder.js b/x-pack/plugins/security_solution/scripts/endpoint/endpoint_agent_emulator.js similarity index 89% rename from x-pack/plugins/security_solution/scripts/endpoint/endpoint_action_responder.js rename to x-pack/plugins/security_solution/scripts/endpoint/endpoint_agent_emulator.js index 3617b8d0d5b3..9eacefa57a2b 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/endpoint_action_responder.js +++ b/x-pack/plugins/security_solution/scripts/endpoint/endpoint_agent_emulator.js @@ -6,4 +6,4 @@ */ require('../../../../../src/setup_node_env'); -require('./action_responder').cli(); +require('./agent_emulator').cli(); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts index a871151ed0b0..b6c1c3a266d9 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts @@ -14,10 +14,12 @@ import { CA_CERT_PATH } from '@kbn/dev-utils'; import { ToolingLog } from '@kbn/tooling-log'; import type { KbnClientOptions } from '@kbn/test'; import { KbnClient } from '@kbn/test'; +import { METADATA_DATASTREAM } from '../../common/endpoint/constants'; import { EndpointMetadataGenerator } from '../../common/endpoint/data_generators/endpoint_metadata_generator'; import { indexHostsAndAlerts } from '../../common/endpoint/index_data'; import { ANCESTRY_LIMIT, EndpointDocGenerator } from '../../common/endpoint/generate_data'; import { fetchStackVersion } from './common/stack_services'; +import { ENDPOINT_ALERTS_INDEX, ENDPOINT_EVENTS_INDEX } from './common/constants'; main(); @@ -130,19 +132,19 @@ async function main() { eventIndex: { alias: 'ei', describe: 'index to store events in', - default: 'logs-endpoint.events.process-default', + default: ENDPOINT_EVENTS_INDEX, type: 'string', }, alertIndex: { alias: 'ai', describe: 'index to store alerts in', - default: 'logs-endpoint.alerts-default', + default: ENDPOINT_ALERTS_INDEX, type: 'string', }, metadataIndex: { alias: 'mi', describe: 'index to store host metadata in', - default: 'metrics-endpoint.metadata-default', + default: METADATA_DATASTREAM, type: 'string', }, policyIndex: { From 1def8b58f9946d5c1002b98c185da03778cdf58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Wed, 19 Oct 2022 20:38:06 +0200 Subject: [PATCH 07/43] =?UTF-8?q?[APM]=20Fallback=20to=20terms=20agg=20sea?= =?UTF-8?q?rch=20if=20terms=20enum=20doesn=E2=80=99t=20return=20results=20?= =?UTF-8?q?(#143619)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [APM] Fallback to terms agg search if terms enum doesn’t return results * Add api test for suggestions --- .../get_suggestions_with_terms_aggregation.ts | 2 +- ....ts => get_suggestions_with_terms_enum.ts} | 47 +-- .../apm/server/routes/suggestions/route.ts | 51 +-- .../tests/suggestions/generate_data.ts | 77 ++++ .../tests/suggestions/suggestions.spec.ts | 361 ++++++++++++------ 5 files changed, 386 insertions(+), 152 deletions(-) rename x-pack/plugins/apm/server/routes/suggestions/{get_suggestions.ts => get_suggestions_with_terms_enum.ts} (56%) create mode 100644 x-pack/test/apm_api_integration/tests/suggestions/generate_data.ts diff --git a/x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_aggregation.ts b/x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_aggregation.ts index 77a7528fbb1a..56ed34805c2f 100644 --- a/x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_aggregation.ts +++ b/x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_aggregation.ts @@ -23,7 +23,7 @@ export async function getSuggestionsWithTermsAggregation({ fieldName: string; fieldValue: string; searchAggregatedTransactions: boolean; - serviceName: string; + serviceName?: string; setup: Setup; size: number; start: number; diff --git a/x-pack/plugins/apm/server/routes/suggestions/get_suggestions.ts b/x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_enum.ts similarity index 56% rename from x-pack/plugins/apm/server/routes/suggestions/get_suggestions.ts rename to x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_enum.ts index dcab43ca26ab..4437a3615189 100644 --- a/x-pack/plugins/apm/server/routes/suggestions/get_suggestions.ts +++ b/x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_enum.ts @@ -8,7 +8,7 @@ import { ProcessorEvent } from '@kbn/observability-plugin/common'; import { getProcessorEventForTransactions } from '../../lib/helpers/transactions'; import { Setup } from '../../lib/helpers/setup_request'; -export async function getSuggestions({ +export async function getSuggestionsWithTermsEnum({ fieldName, fieldValue, searchAggregatedTransactions, @@ -27,30 +27,33 @@ export async function getSuggestions({ }) { const { apmEventClient } = setup; - const response = await apmEventClient.termsEnum('get_suggestions', { - apm: { - events: [ - getProcessorEventForTransactions(searchAggregatedTransactions), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - case_insensitive: true, - field: fieldName, - size, - string: fieldValue, - index_filter: { - range: { - ['@timestamp']: { - gte: start, - lte: end, - format: 'epoch_millis', + const response = await apmEventClient.termsEnum( + 'get_suggestions_with_terms_enum', + { + apm: { + events: [ + getProcessorEventForTransactions(searchAggregatedTransactions), + ProcessorEvent.error, + ProcessorEvent.metric, + ], + }, + body: { + case_insensitive: true, + field: fieldName, + size, + string: fieldValue, + index_filter: { + range: { + ['@timestamp']: { + gte: start, + lte: end, + format: 'epoch_millis', + }, }, }, }, - }, - }); + } + ); return { terms: response.terms }; } diff --git a/x-pack/plugins/apm/server/routes/suggestions/route.ts b/x-pack/plugins/apm/server/routes/suggestions/route.ts index 92a64da42eca..f0396ac62ca5 100644 --- a/x-pack/plugins/apm/server/routes/suggestions/route.ts +++ b/x-pack/plugins/apm/server/routes/suggestions/route.ts @@ -7,7 +7,7 @@ import * as t from 'io-ts'; import { maxSuggestions } from '@kbn/observability-plugin/common'; -import { getSuggestions } from './get_suggestions'; +import { getSuggestionsWithTermsEnum } from './get_suggestions_with_terms_enum'; import { getSuggestionsWithTermsAggregation } from './get_suggestions_with_terms_aggregation'; import { getSearchTransactionsEvents } from '../../lib/helpers/transactions'; import { setupRequest } from '../../lib/helpers/setup_request'; @@ -41,28 +41,35 @@ const suggestionsRoute = createApmServerRoute({ maxSuggestions ); - const suggestions = serviceName - ? await getSuggestionsWithTermsAggregation({ - fieldName, - fieldValue, - searchAggregatedTransactions, - serviceName, - setup, - size, - start, - end, - }) - : await getSuggestions({ - fieldName, - fieldValue, - searchAggregatedTransactions, - setup, - size, - start, - end, - }); + if (!serviceName) { + const suggestions = await getSuggestionsWithTermsEnum({ + fieldName, + fieldValue, + searchAggregatedTransactions, + setup, + size, + start, + end, + }); - return suggestions; + // if no terms are found using terms enum it will fall back to using ordinary terms agg search + // This is useful because terms enum can only find terms that start with the search query + // whereas terms agg approach can find terms that contain the search query + if (suggestions.terms.length > 0) { + return suggestions; + } + } + + return getSuggestionsWithTermsAggregation({ + fieldName, + fieldValue, + searchAggregatedTransactions, + serviceName, + setup, + size, + start, + end, + }); }, }); diff --git a/x-pack/test/apm_api_integration/tests/suggestions/generate_data.ts b/x-pack/test/apm_api_integration/tests/suggestions/generate_data.ts new file mode 100644 index 000000000000..13d6359e0a73 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/suggestions/generate_data.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { apm, timerange } from '@kbn/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace'; +import { times } from 'lodash'; + +export async function generateData({ + synthtraceEsClient, + start, + end, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; +}) { + const services = times(5).flatMap((serviceId) => { + return ['go', 'java'].flatMap((agentName) => { + return ['production', 'development', 'staging'].flatMap((environment) => { + return times(5).flatMap((envId) => { + const service = apm + .service({ + name: `${agentName}-${serviceId}`, + environment: `${environment}-${envId}`, + agentName, + }) + .instance('instance-a'); + + return service; + }); + }); + }); + }); + + const transactionNames = [ + 'GET /api/product/:id', + 'PUT /api/product/:id', + 'GET /api/user/:id', + 'PUT /api/user/:id', + ]; + + const phpService = apm + .service({ + name: `custom-php-service`, + environment: `custom-php-environment`, + agentName: 'php', + }) + .instance('instance-a'); + + const docs = timerange(start, end) + .ratePerMinute(1) + .generator((timestamp) => { + const autoGeneratedDocs = services.flatMap((service) => { + return transactionNames.flatMap((transactionName) => { + return service + .transaction({ transactionName, transactionType: 'my-custom-type' }) + .timestamp(timestamp) + .duration(1000); + }); + }); + + const customDoc = phpService + .transaction({ + transactionName: 'GET /api/php/memory', + transactionType: 'custom-php-type', + }) + .timestamp(timestamp) + .duration(1000); + + return [...autoGeneratedDocs, customDoc]; + }); + + return await synthtraceEsClient.index(docs); +} diff --git a/x-pack/test/apm_api_integration/tests/suggestions/suggestions.spec.ts b/x-pack/test/apm_api_integration/tests/suggestions/suggestions.spec.ts index 692cd1c0cf7f..db15db23776c 100644 --- a/x-pack/test/apm_api_integration/tests/suggestions/suggestions.spec.ts +++ b/x-pack/test/apm_api_integration/tests/suggestions/suggestions.spec.ts @@ -7,139 +7,286 @@ import { SERVICE_ENVIRONMENT, SERVICE_NAME, + TRANSACTION_NAME, TRANSACTION_TYPE, } from '@kbn/apm-plugin/common/elasticsearch_fieldnames'; -import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { generateData } from './generate_data'; + +const startNumber = new Date('2021-01-01T00:00:00.000Z').getTime(); +const endNumber = new Date('2021-01-01T00:05:00.000Z').getTime() - 1; + +const start = new Date(startNumber).toISOString(); +const end = new Date(endNumber).toISOString(); export default function suggestionsTests({ getService }: FtrProviderContext) { const registry = getService('registry'); const apmApiClient = getService('apmApiClient'); - const archiveName = 'apm_8.0.0'; - const { start, end } = archives_metadata[archiveName]; - - registry.when( - 'suggestions when data is loaded', - { config: 'basic', archives: [archiveName] }, - () => { - describe('with environment', () => { - describe('with an empty string parameter', () => { - it('returns all environments', async () => { - const { body } = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/suggestions', - params: { query: { fieldName: SERVICE_ENVIRONMENT, fieldValue: '', start, end } }, - }); - - expectSnapshot(body).toMatchInline(` - Object { - "terms": Array [ - "production", - "testing", - ], - } - `); + const synthtraceEsClient = getService('synthtraceEsClient'); + + registry.when('suggestions when data is loaded', { config: 'basic', archives: [] }, async () => { + before(async () => { + await generateData({ + synthtraceEsClient, + start: startNumber, + end: endNumber, + }); + }); + + after(() => synthtraceEsClient.clean()); + + describe(`field: ${SERVICE_ENVIRONMENT}`, () => { + describe('when fieldValue is empty', () => { + it('returns all environments', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { + query: { fieldName: SERVICE_ENVIRONMENT, fieldValue: '', start, end }, + }, + }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "custom-php-environment", + "development-0", + "development-1", + "development-2", + "development-3", + "development-4", + "production-0", + "production-1", + "production-2", + "production-3", + "production-4", + "staging-0", + "staging-1", + "staging-2", + "staging-3", + "staging-4", + ] + `); + }); + }); + + describe('when fieldValue is not empty', () => { + it('returns environments that start with the fieldValue', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: SERVICE_ENVIRONMENT, fieldValue: 'prod', start, end } }, + }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "production-0", + "production-1", + "production-2", + "production-3", + "production-4", + ] + `); + }); + + it('returns environments that contain the fieldValue', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { + query: { fieldName: SERVICE_ENVIRONMENT, fieldValue: 'evelopment', start, end }, + }, + }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "development-0", + "development-1", + "development-2", + "development-3", + "development-4", + ] + `); + }); + + it('returns no results if nothing matches', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { + query: { fieldName: SERVICE_ENVIRONMENT, fieldValue: 'foobar', start, end }, + }, + }); + + expect(body.terms).to.eql([]); + }); + }); + }); + + describe(`field: ${SERVICE_NAME}`, () => { + describe('when fieldValue is empty', () => { + it('returns all service names', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: SERVICE_NAME, fieldValue: '', start, end } }, + }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "custom-php-service", + "go-0", + "go-1", + "go-2", + "go-3", + "go-4", + "java-0", + "java-1", + "java-2", + "java-3", + "java-4", + ] + `); + }); + }); + + describe('when fieldValue is not empty', () => { + it('returns services that start with the fieldValue', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: SERVICE_NAME, fieldValue: 'java', start, end } }, + }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "java-0", + "java-1", + "java-2", + "java-3", + "java-4", + ] + `); + }); + + it('returns services that contains the fieldValue', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: SERVICE_NAME, fieldValue: '1', start, end } }, }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "go-1", + "java-1", + ] + `); }); + }); + }); + + describe(`field: ${TRANSACTION_TYPE}`, () => { + describe('when fieldValue is empty', () => { + it('returns all transaction types', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: TRANSACTION_TYPE, fieldValue: '', start, end } }, + }); - describe('with a string parameter', () => { - it('returns items matching the string parameter', async () => { - const { body } = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/suggestions', - params: { query: { fieldName: SERVICE_ENVIRONMENT, fieldValue: 'pr', start, end } }, - }); - - expectSnapshot(body).toMatchInline(` - Object { - "terms": Array [ - "production", - ], - } + expectSnapshot(body.terms).toMatchInline(` + Array [ + "custom-php-type", + "my-custom-type", + ] `); + }); + }); + + describe('with a string parameter', () => { + it('returns items matching the string parameter', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: TRANSACTION_TYPE, fieldValue: 'custom', start, end } }, }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "custom-php-type", + ] + `); }); }); + }); - describe('with service name', () => { - describe('with an empty string parameter', () => { - it('returns all services', async () => { - const { body } = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/suggestions', - params: { query: { fieldName: SERVICE_NAME, fieldValue: '', start, end } }, - }); - - expectSnapshot(body).toMatchInline(` - Object { - "terms": Array [ - "auditbeat", - "opbeans-dotnet", - "opbeans-go", - "opbeans-java", - "opbeans-node", - "opbeans-python", - "opbeans-ruby", - "opbeans-rum", - ], - } - `); + describe(`field: ${TRANSACTION_NAME}`, () => { + describe('when fieldValue is empty', () => { + it('returns all transaction names', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: TRANSACTION_NAME, fieldValue: '', start, end } }, }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "GET /api/php/memory", + "GET /api/product/:id", + "GET /api/user/:id", + "PUT /api/product/:id", + "PUT /api/user/:id", + ] + `); }); + }); - describe('with a string parameter', () => { - it('returns items matching the string parameter', async () => { - const { body } = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/suggestions', - params: { query: { fieldName: SERVICE_NAME, fieldValue: 'aud', start, end } }, - }); - - expectSnapshot(body).toMatchInline(` - Object { - "terms": Array [ - "auditbeat", - ], - } - `); + describe('with a string parameter', () => { + it('returns items matching the string parameter', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: TRANSACTION_NAME, fieldValue: 'product', start, end } }, }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "GET /api/product/:id", + "PUT /api/product/:id", + ] + `); }); }); - describe('with transaction type', () => { - describe('with an empty string parameter', () => { - it('returns all transaction types', async () => { - const { body } = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/suggestions', - params: { query: { fieldName: TRANSACTION_TYPE, fieldValue: '', start, end } }, - }); - - expectSnapshot(body).toMatchInline(` - Object { - "terms": Array [ - "Worker", - "celery", - "page-load", - "request", - ], - } - `); + describe('when limiting the suggestions to a specific service', () => { + it('returns items matching the string parameter', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { + query: { + serviceName: 'custom-php-service', + fieldName: TRANSACTION_NAME, + fieldValue: '', + start, + end, + }, + }, }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "GET /api/php/memory", + ] + `); }); - describe('with a string parameter', () => { - it('returns items matching the string parameter', async () => { - const { body } = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/suggestions', - params: { query: { fieldName: TRANSACTION_TYPE, fieldValue: 'w', start, end } }, - }); - - expectSnapshot(body).toMatchInline(` - Object { - "terms": Array [ - "Worker", - ], - } - `); + it('does not return transactions from other services', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { + query: { + serviceName: 'custom-php-service', + fieldName: TRANSACTION_NAME, + fieldValue: 'product', + start, + end, + }, + }, }); + + expect(body.terms).to.eql([]); }); }); - } - ); + }); + }); } From 6c5d816c0126af4d43eb42f4305f5332ba8833cd Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 19 Oct 2022 12:13:18 -0700 Subject: [PATCH 08/43] [Security Solution][Exceptions] - Update add/edit exception flyouts (#143127) * squashed commit of updates to add/edit flyouts for exception, added cypress tests and unit tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Devin W. Hurley --- .../src/field/__tests__/index.test.tsx | 26 + .../src/field/__tests__/use_field.test.ts | 12 + .../src/field/index.tsx | 25 + .../src/field/types.ts | 1 + .../src/field/use_field.tsx | 27 +- .../src/helpers/index.ts | 136 +- .../builder/entry_renderer.test.tsx | 74 +- .../components/builder/entry_renderer.tsx | 26 +- .../builder/exception_item_renderer.tsx | 7 +- .../builder/exception_items_renderer.tsx | 31 +- .../components/builder/helpers.test.ts | 326 +++- .../components/builder/translations.ts | 8 + .../common/ecs/rule/index.ts | 1 + .../flyout_validation.cy.ts} | 93 +- .../alerts_table_flow/add_exception.cy.ts | 240 ++- .../exceptions_table.cy.ts | 22 +- .../add_edit_endpoint_exception.cy.ts | 179 +++ .../add_edit_exception.cy.ts | 336 +++++ ....ts => add_edit_exception_data_view.cy.ts} | 72 +- .../rule_details_flow/add_exception.spec.ts | 221 --- .../rule_details_flow/edit_exception.spec.ts | 143 -- .../edit_exception_data_view.spec.ts | 144 -- ...nly_view.spect.ts => read_only_view.cy.ts} | 0 .../cypress/screens/alerts.ts | 8 + .../cypress/screens/exceptions.ts | 35 +- .../cypress/screens/rule_details.ts | 2 + .../security_solution/cypress/tasks/alerts.ts | 44 +- .../cypress/tasks/api_calls/exceptions.ts | 8 + .../cypress/tasks/exceptions.ts | 76 +- .../cypress/tasks/rule_details.ts | 61 +- .../add_exception_flyout/index.test.tsx | 1308 +++++++++++++---- .../components/add_exception_flyout/index.tsx | 858 +++++------ .../add_exception_flyout/reducer.ts | 250 ++++ .../add_exception_flyout/translations.ts | 82 +- .../use_add_new_exceptions.ts | 148 ++ .../all_items.test.tsx | 9 +- .../all_exception_items_table/all_items.tsx | 15 +- .../empty_viewer_state.test.tsx | 18 +- .../empty_viewer_state.tsx | 19 +- .../all_exception_items_table/index.test.tsx | 64 +- .../all_exception_items_table/index.tsx | 122 +- .../search_bar.test.tsx | 12 +- .../all_exception_items_table/search_bar.tsx | 18 +- .../all_exception_items_table/translations.ts | 59 +- .../utility_bar.test.tsx | 6 +- .../edit_exception_flyout/index.test.tsx | 679 ++++++--- .../edit_exception_flyout/index.tsx | 695 ++++----- .../edit_exception_flyout/reducer.ts | 116 ++ .../edit_exception_flyout/translations.ts | 87 +- .../use_edit_exception.tsx | 74 + .../exception_item_card/index.test.tsx | 160 +- .../components/exception_item_card/index.tsx | 25 +- .../exception_item_card/meta.test.tsx | 388 ++--- .../components/exception_item_card/meta.tsx | 126 +- .../exception_item_card/translations.ts | 57 +- .../item_conditions/index.tsx | 1 + .../components/item_comments/index.test.tsx | 1 - .../components/item_comments/index.tsx | 54 +- .../rule_exceptions/logic/translations.ts | 22 + .../logic/use_add_exception.test.tsx | 423 ------ .../logic/use_add_exception.tsx | 192 --- .../logic/use_add_rule_exception.tsx | 72 + .../logic/use_close_alerts.tsx | 133 ++ .../logic/use_create_update_exception.tsx | 68 + .../logic/use_exception_flyout_data.tsx | 99 ++ .../rule_exceptions/utils/helpers.test.tsx | 74 +- .../rule_exceptions/utils/helpers.tsx | 108 +- .../timeline_actions/alert_context_menu.tsx | 108 +- .../use_add_exception_actions.tsx | 8 +- .../use_add_exception_flyout.tsx | 55 +- .../components/take_action_dropdown/index.tsx | 6 +- .../exceptions/get_es_query_filter.ts | 8 +- .../containers/detection_engine/rules/api.ts | 31 + .../detection_engine/rules/details/index.tsx | 7 +- .../event_details/flyout/footer.tsx | 28 +- .../rules/find_rule_exceptions_route.ts | 10 +- .../translations/translations/fr-FR.json | 45 - .../translations/translations/ja-JP.json | 45 - .../translations/translations/zh-CN.json | 45 - .../group1/find_rule_exception_references.ts | 2 +- 80 files changed, 5754 insertions(+), 3670 deletions(-) rename x-pack/plugins/security_solution/cypress/e2e/exceptions/{exceptions_flyout.cy.ts => add_edit_flyout/flyout_validation.cy.ts} (83%) rename x-pack/plugins/security_solution/cypress/e2e/exceptions/{ => exceptions_management_flow}/exceptions_table.cy.ts (91%) create mode 100644 x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts create mode 100644 x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts rename x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/{add_exception_data_view.spect.ts => add_edit_exception_data_view.cy.ts} (63%) delete mode 100644 x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception.spec.ts delete mode 100644 x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception.spec.ts delete mode 100644 x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception_data_view.spec.ts rename x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/{read_only_view.spect.ts => read_only_view.cy.ts} (100%) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/use_add_new_exceptions.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/use_edit_exception.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/translations.ts delete mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_rule_exception.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_close_alerts.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_create_update_exception.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx diff --git a/packages/kbn-securitysolution-autocomplete/src/field/__tests__/index.test.tsx b/packages/kbn-securitysolution-autocomplete/src/field/__tests__/index.test.tsx index dcdba80c268d..59ff70f60d59 100644 --- a/packages/kbn-securitysolution-autocomplete/src/field/__tests__/index.test.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field/__tests__/index.test.tsx @@ -114,4 +114,30 @@ describe('FieldComponent', () => { expect(wrapper.getByTestId('fieldAutocompleteComboBox')).toHaveTextContent('_source') ); }); + + it('it allows custom user input if "acceptsCustomOptions" is "true"', async () => { + const mockOnChange = jest.fn(); + const wrapper = render( + + ); + + const fieldAutocompleteComboBox = wrapper.getByTestId('comboBoxSearchInput'); + fireEvent.change(fieldAutocompleteComboBox, { target: { value: 'custom' } }); + await waitFor(() => + expect(wrapper.getByTestId('fieldAutocompleteComboBox')).toHaveTextContent('custom') + ); + }); }); diff --git a/packages/kbn-securitysolution-autocomplete/src/field/__tests__/use_field.test.ts b/packages/kbn-securitysolution-autocomplete/src/field/__tests__/use_field.test.ts index 68748bf82a20..d060f585e911 100644 --- a/packages/kbn-securitysolution-autocomplete/src/field/__tests__/use_field.test.ts +++ b/packages/kbn-securitysolution-autocomplete/src/field/__tests__/use_field.test.ts @@ -346,6 +346,18 @@ describe('useField', () => { ]); }); }); + it('should invoke onChange with custom option if one is sent', () => { + const { result } = renderHook(() => useField({ indexPattern, onChange: onChangeMock })); + act(() => { + result.current.handleCreateCustomOption('madeUpField'); + expect(onChangeMock).toHaveBeenCalledWith([ + { + name: 'madeUpField', + type: 'text', + }, + ]); + }); + }); }); describe('fieldWidth', () => { diff --git a/packages/kbn-securitysolution-autocomplete/src/field/index.tsx b/packages/kbn-securitysolution-autocomplete/src/field/index.tsx index dad13434779e..704433b79560 100644 --- a/packages/kbn-securitysolution-autocomplete/src/field/index.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field/index.tsx @@ -25,6 +25,7 @@ export const FieldComponent: React.FC = ({ onChange, placeholder, selectedField, + acceptsCustomOptions = false, }): JSX.Element => { const { isInvalid, @@ -35,6 +36,7 @@ export const FieldComponent: React.FC = ({ renderFields, handleTouch, handleValuesChange, + handleCreateCustomOption, } = useField({ indexPattern, fieldTypeFilter, @@ -43,6 +45,29 @@ export const FieldComponent: React.FC = ({ fieldInputWidth, onChange, }); + + if (acceptsCustomOptions) { + return ( + + ); + } + return ( { const [touched, setIsTouched] = useState(false); - const { availableFields, selectedFields } = useMemo( - () => getComboBoxFields(indexPattern, selectedField, fieldTypeFilter), - [indexPattern, fieldTypeFilter, selectedField] - ); + const [customOption, setCustomOption] = useState(null); + + const { availableFields, selectedFields } = useMemo(() => { + const indexPatternsToUse = + customOption != null && indexPattern != null + ? { ...indexPattern, fields: [...indexPattern?.fields, customOption] } + : indexPattern; + return getComboBoxFields(indexPatternsToUse, selectedField, fieldTypeFilter); + }, [indexPattern, fieldTypeFilter, selectedField, customOption]); const { comboOptions, labels, selectedComboOptions, disabledLabelTooltipTexts } = useMemo( () => getComboBoxProps({ availableFields, selectedFields }), @@ -117,6 +122,19 @@ export const useField = ({ [availableFields, labels, onChange] ); + const handleCreateCustomOption = useCallback( + (val: string) => { + const normalizedSearchValue = val.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + setCustomOption({ name: val, type: 'text' }); + onChange([{ name: val, type: 'text' }]); + }, + [onChange] + ); + const handleTouch = useCallback((): void => { setIsTouched(true); }, [setIsTouched]); @@ -161,5 +179,6 @@ export const useField = ({ renderFields, handleTouch, handleValuesChange, + handleCreateCustomOption, }; }; diff --git a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts index 6f4bc7d51052..945afbbc8604 100644 --- a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts @@ -14,7 +14,6 @@ import { EntriesArray, Entry, EntryNested, - ExceptionListItemSchema, ExceptionListType, ListSchema, NamespaceType, @@ -27,6 +26,8 @@ import { entry, exceptionListItemSchema, nestedEntryItem, + CreateRuleExceptionListItemSchema, + createRuleExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { DataViewBase, @@ -55,6 +56,7 @@ import { EmptyEntry, EmptyNestedEntry, ExceptionsBuilderExceptionItem, + ExceptionsBuilderReturnExceptionItem, FormattedBuilderEntry, OperatorOption, } from '../types'; @@ -65,59 +67,60 @@ export const isEntryNested = (item: BuilderEntry): item is EntryNested => { export const filterExceptionItems = ( exceptions: ExceptionsBuilderExceptionItem[] -): Array => { - return exceptions.reduce>( - (acc, exception) => { - const entries = exception.entries.reduce((nestedAcc, singleEntry) => { - const strippedSingleEntry = removeIdFromItem(singleEntry); - - if (entriesNested.is(strippedSingleEntry)) { - const nestedEntriesArray = strippedSingleEntry.entries.filter((singleNestedEntry) => { - const noIdSingleNestedEntry = removeIdFromItem(singleNestedEntry); - const [validatedNestedEntry] = validate(noIdSingleNestedEntry, nestedEntryItem); - return validatedNestedEntry != null; - }); - const noIdNestedEntries = nestedEntriesArray.map((singleNestedEntry) => - removeIdFromItem(singleNestedEntry) - ); - - const [validatedNestedEntry] = validate( - { ...strippedSingleEntry, entries: noIdNestedEntries }, - entriesNested - ); - - if (validatedNestedEntry != null) { - return [...nestedAcc, { ...singleEntry, entries: nestedEntriesArray }]; - } - return nestedAcc; - } else { - const [validatedEntry] = validate(strippedSingleEntry, entry); - - if (validatedEntry != null) { - return [...nestedAcc, singleEntry]; - } - return nestedAcc; +): ExceptionsBuilderReturnExceptionItem[] => { + return exceptions.reduce((acc, exception) => { + const entries = exception.entries.reduce((nestedAcc, singleEntry) => { + const strippedSingleEntry = removeIdFromItem(singleEntry); + if (entriesNested.is(strippedSingleEntry)) { + const nestedEntriesArray = strippedSingleEntry.entries.filter((singleNestedEntry) => { + const noIdSingleNestedEntry = removeIdFromItem(singleNestedEntry); + const [validatedNestedEntry] = validate(noIdSingleNestedEntry, nestedEntryItem); + return validatedNestedEntry != null; + }); + const noIdNestedEntries = nestedEntriesArray.map((singleNestedEntry) => + removeIdFromItem(singleNestedEntry) + ); + + const [validatedNestedEntry] = validate( + { ...strippedSingleEntry, entries: noIdNestedEntries }, + entriesNested + ); + + if (validatedNestedEntry != null) { + return [...nestedAcc, { ...singleEntry, entries: nestedEntriesArray }]; } - }, []); - - if (entries.length === 0) { - return acc; + return nestedAcc; + } else { + const [validatedEntry] = validate(strippedSingleEntry, entry); + if (validatedEntry != null) { + return [...nestedAcc, singleEntry]; + } + return nestedAcc; } + }, []); - const item = { ...exception, entries }; + if (entries.length === 0) { + return acc; + } - if (exceptionListItemSchema.is(item)) { - return [...acc, item]; - } else if (createExceptionListItemSchema.is(item)) { - const { meta, ...rest } = item; - const itemSansMetaId: CreateExceptionListItemSchema = { ...rest, meta: undefined }; - return [...acc, itemSansMetaId]; - } else { - return acc; - } - }, - [] - ); + const item = { ...exception, entries }; + + if (exceptionListItemSchema.is(item)) { + return [...acc, item]; + } else if ( + createExceptionListItemSchema.is(item) || + createRuleExceptionListItemSchema.is(item) + ) { + const { meta, ...rest } = item; + const itemSansMetaId: CreateExceptionListItemSchema | CreateRuleExceptionListItemSchema = { + ...rest, + meta: undefined, + }; + return [...acc, itemSansMetaId]; + } else { + return acc; + } + }, []); }; export const addIdToEntries = (entries: EntriesArray): EntriesArray => { @@ -136,15 +139,15 @@ export const addIdToEntries = (entries: EntriesArray): EntriesArray => { export const getNewExceptionItem = ({ listId, namespaceType, - ruleName, + name, }: { listId: string | undefined; namespaceType: NamespaceType | undefined; - ruleName: string; + name: string; }): CreateExceptionListItemBuilderSchema => { return { comments: [], - description: 'Exception list item', + description: `Exception list item`, entries: addIdToEntries([ { field: '', @@ -158,7 +161,7 @@ export const getNewExceptionItem = ({ meta: { temporaryUuid: uuid.v4(), }, - name: `${ruleName} - exception list item`, + name, namespace_type: namespaceType, tags: [], type: 'simple', @@ -769,13 +772,15 @@ export const getCorrespondingKeywordField = ({ * @param parent nested entries hold copy of their parent for use in various logic * @param parentIndex corresponds to the entry index, this might seem obvious, but * was added to ensure that nested items could be identified with their parent entry + * @param allowCustomFieldOptions determines if field must be found to match in indexPattern or not */ export const getFormattedBuilderEntry = ( indexPattern: DataViewBase, item: BuilderEntry, itemIndex: number, parent: EntryNested | undefined, - parentIndex: number | undefined + parentIndex: number | undefined, + allowCustomFieldOptions: boolean ): FormattedBuilderEntry => { const { fields } = indexPattern; const field = parent != null ? `${parent.field}.${item.field}` : item.field; @@ -800,10 +805,14 @@ export const getFormattedBuilderEntry = ( value: getEntryValue(item), }; } else { + const fieldToUse = allowCustomFieldOptions + ? foundField ?? { name: item.field, type: 'keyword' } + : foundField; + return { correspondingKeywordField, entryIndex: itemIndex, - field: foundField, + field: fieldToUse, id: item.id != null ? item.id : `${itemIndex}`, nested: undefined, operator: getExceptionOperatorSelect(item), @@ -819,8 +828,7 @@ export const getFormattedBuilderEntry = ( * * @param patterns DataViewBase containing available fields on rule index * @param entries exception item entries - * @param addNested boolean noting whether or not UI is currently - * set to add a nested field + * @param allowCustomFieldOptions determines if field must be found to match in indexPattern or not * @param parent nested entries hold copy of their parent for use in various logic * @param parentIndex corresponds to the entry index, this might seem obvious, but * was added to ensure that nested items could be identified with their parent entry @@ -828,6 +836,7 @@ export const getFormattedBuilderEntry = ( export const getFormattedBuilderEntries = ( indexPattern: DataViewBase, entries: BuilderEntry[], + allowCustomFieldOptions: boolean, parent?: EntryNested, parentIndex?: number ): FormattedBuilderEntry[] => { @@ -839,7 +848,8 @@ export const getFormattedBuilderEntries = ( item, index, parent, - parentIndex + parentIndex, + allowCustomFieldOptions ); return [...acc, newItemEntry]; } else { @@ -869,7 +879,13 @@ export const getFormattedBuilderEntries = ( } if (isEntryNested(item)) { - const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index); + const nestedItems = getFormattedBuilderEntries( + indexPattern, + item.entries, + allowCustomFieldOptions, + item, + index + ); return [...acc, parentEntry, ...nestedItems]; } diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx index 6d6fed518186..b272b5ec3e36 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx @@ -31,6 +31,7 @@ import { ReactWrapper, mount } from 'enzyme'; import { getFoundListsBySizeSchemaMock } from '../../../../common/schemas/response/found_lists_by_size_schema.mock'; import { BuilderEntryItem } from './entry_renderer'; +import * as i18n from './translations'; jest.mock('@kbn/securitysolution-list-hooks'); jest.mock('@kbn/securitysolution-utils'); @@ -81,11 +82,78 @@ describe('BuilderEntryItem', () => { onChange={jest.fn()} setErrorsExist={jest.fn()} setWarningsExist={jest.fn()} - showLabel={true} + showLabel /> ); expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldFormRow"]')).not.toEqual(0); + expect(wrapper.find('.euiFormHelpText.euiFormRow__text').exists()).toBeFalsy(); + }); + + test('it renders custom option text if "allowCustomOptions" is "true" and it is not a nested entry', () => { + wrapper = mount( + + ); + + expect(wrapper.find('.euiFormHelpText.euiFormRow__text').at(0).text()).toEqual( + i18n.CUSTOM_COMBOBOX_OPTION_TEXT + ); + }); + + test('it does not render custom option text when "allowCustomOptions" is "true" and it is a nested entry', () => { + wrapper = mount( + + ); + + expect(wrapper.find('.euiFormHelpText.euiFormRow__text').exists()).toBeFalsy(); }); test('it renders field values correctly when operator is "isOperator"', () => { @@ -259,7 +327,7 @@ describe('BuilderEntryItem', () => { onChange={jest.fn()} setErrorsExist={jest.fn()} setWarningsExist={jest.fn()} - showLabel={true} + showLabel /> ); @@ -297,7 +365,7 @@ describe('BuilderEntryItem', () => { onChange={jest.fn()} setErrorsExist={jest.fn()} setWarningsExist={jest.fn()} - showLabel={true} + showLabel /> ); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx index 8f0bc15bd7da..2df0b4b41a2f 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx @@ -75,6 +75,7 @@ export interface EntryItemProps { setWarningsExist: (arg: boolean) => void; isDisabled?: boolean; operatorsList?: OperatorOption[]; + allowCustomOptions?: boolean; } export const BuilderEntryItem: React.FC = ({ @@ -93,6 +94,7 @@ export const BuilderEntryItem: React.FC = ({ showLabel, isDisabled = false, operatorsList, + allowCustomOptions = false, }): JSX.Element => { const handleError = useCallback( (err: boolean): void => { @@ -163,9 +165,9 @@ export const BuilderEntryItem: React.FC = ({ const isFieldComponentDisabled = useMemo( (): boolean => isDisabled || - indexPattern == null || - (indexPattern != null && indexPattern.fields.length === 0), - [isDisabled, indexPattern] + (!allowCustomOptions && + (indexPattern == null || (indexPattern != null && indexPattern.fields.length === 0))), + [isDisabled, indexPattern, allowCustomOptions] ); const renderFieldInput = useCallback( @@ -190,6 +192,7 @@ export const BuilderEntryItem: React.FC = ({ isLoading={false} isDisabled={isDisabled || indexPattern == null} onChange={handleFieldChange} + acceptsCustomOptions={entry.nested == null} data-test-subj="exceptionBuilderEntryField" /> ); @@ -199,6 +202,11 @@ export const BuilderEntryItem: React.FC = ({ {comboBox} @@ -206,7 +214,16 @@ export const BuilderEntryItem: React.FC = ({ ); } else { return ( - + {comboBox} ); @@ -220,6 +237,7 @@ export const BuilderEntryItem: React.FC = ({ handleFieldChange, osTypes, isDisabled, + allowCustomOptions, ] ); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx index 84c18baf5156..d891c1a5eea0 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx @@ -63,6 +63,7 @@ interface BuilderExceptionListItemProps { onlyShowListOperators?: boolean; isDisabled?: boolean; operatorsList?: OperatorOption[]; + allowCustomOptions?: boolean; } export const BuilderExceptionListItemComponent = React.memo( @@ -85,6 +86,7 @@ export const BuilderExceptionListItemComponent = React.memo { const handleEntryChange = useCallback( (entry: BuilderEntry, entryIndex: number): void => { @@ -117,9 +119,9 @@ export const BuilderExceptionListItemComponent = React.memo { const hasIndexPatternAndEntries = indexPattern != null && exceptionItem.entries.length > 0; return hasIndexPatternAndEntries - ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) + ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries, allowCustomOptions) : []; - }, [exceptionItem.entries, indexPattern]); + }, [exceptionItem.entries, indexPattern, allowCustomOptions]); return ( @@ -157,6 +159,7 @@ export const BuilderExceptionListItemComponent = React.memo DataViewBase; onChange: (arg: OnChangeProps) => void; - exceptionItemName?: string; ruleName?: string; isDisabled?: boolean; operatorsList?: OperatorOption[]; + exceptionItemName?: string; + allowCustomFieldOptions?: boolean; } export const ExceptionBuilderComponent = ({ @@ -118,6 +119,7 @@ export const ExceptionBuilderComponent = ({ isDisabled = false, osTypes, operatorsList, + allowCustomFieldOptions = false, }: ExceptionBuilderProps): JSX.Element => { const [ { @@ -229,7 +231,6 @@ export const ExceptionBuilderComponent = ({ }, ...exceptions.slice(index + 1), ]; - setUpdateExceptions(updatedExceptions); }, [setUpdateExceptions, exceptions] @@ -278,7 +279,6 @@ export const ExceptionBuilderComponent = ({ ...lastException, entries: [...entries, isNested ? getDefaultNestedEmptyEntry() : getDefaultEmptyEntry()], }; - setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); }, [setUpdateExceptions, exceptions] @@ -290,11 +290,12 @@ export const ExceptionBuilderComponent = ({ // would then be arbitrary, decided to just create a new exception list item const newException = getNewExceptionItem({ listId, + name: exceptionItemName ?? `${ruleName ?? 'Rule'} - Exception item`, namespaceType: listNamespaceType, - ruleName: exceptionItemName ?? `${ruleName ?? 'Rule'} - Exception item`, }); + setUpdateExceptions([...exceptions, { ...newException }]); - }, [listId, listNamespaceType, exceptionItemName, ruleName, setUpdateExceptions, exceptions]); + }, [setUpdateExceptions, exceptions, listId, listNamespaceType, ruleName, exceptionItemName]); // The builder can have existing exception items, or new exception items that have yet // to be created (and thus lack an id), this was creating some React bugs with relying @@ -334,7 +335,6 @@ export const ExceptionBuilderComponent = ({ }, ], }; - setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); } else { setUpdateExceptions(exceptions); @@ -359,19 +359,23 @@ export const ExceptionBuilderComponent = ({ handleAddNewExceptionItemEntry(); }, [handleAddNewExceptionItemEntry, setUpdateOrDisabled, setUpdateAddNested]); + const memoExceptionItems = useMemo(() => { + return filterExceptionItems(exceptions); + }, [exceptions]); + + // useEffect(() => { + // setUpdateExceptions([]); + // }, [osTypes, setUpdateExceptions]); + // Bubble up changes to parent useEffect(() => { onChange({ errorExists: errorExists > 0, - exceptionItems: filterExceptionItems(exceptions), + exceptionItems: memoExceptionItems, exceptionsToDelete, warningExists: warningExists > 0, }); - }, [onChange, exceptionsToDelete, exceptions, errorExists, warningExists]); - - useEffect(() => { - setUpdateExceptions([]); - }, [osTypes, setUpdateExceptions]); + }, [onChange, exceptionsToDelete, memoExceptionItems, errorExists, warningExists]); // Defaults builder to never be sans entry, instead // always falls back to an empty entry if user deletes all @@ -436,6 +440,7 @@ export const ExceptionBuilderComponent = ({ osTypes={osTypes} isDisabled={isDisabled} operatorsList={operatorsList} + allowCustomOptions={allowCustomFieldOptions} /> diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts index 73bf42e767dd..38323fcf88cb 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts @@ -6,13 +6,11 @@ */ import { - CreateExceptionListItemSchema, EntryExists, EntryList, EntryMatch, EntryMatchAny, EntryNested, - ExceptionListItemSchema, ExceptionListType, ListOperatorEnum as OperatorEnum, ListOperatorTypeEnum as OperatorTypeEnum, @@ -24,6 +22,7 @@ import { EXCEPTION_OPERATORS_SANS_LISTS, EmptyEntry, ExceptionsBuilderExceptionItem, + ExceptionsBuilderReturnExceptionItem, FormattedBuilderEntry, OperatorOption, doesNotExistOperator, @@ -1056,10 +1055,10 @@ describe('Exception builder helpers', () => { }); describe('#getFormattedBuilderEntries', () => { - test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { + test('it returns formatted entry with field undefined if it unable to find a matching index pattern field and "allowCustomFieldOptions" is "false"', () => { const payloadIndexPattern = getMockIndexPattern(); const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems, false); const expected: FormattedBuilderEntry[] = [ { correspondingKeywordField: undefined, @@ -1075,13 +1074,35 @@ describe('Exception builder helpers', () => { expect(output).toEqual(expected); }); + test('it returns formatted entry with field even if it is unable to find a matching index pattern field and "allowCustomFieldOptions" is "true"', () => { + const payloadIndexPattern = getMockIndexPattern(); + const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems, true); + const expected: FormattedBuilderEntry[] = [ + { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + name: 'host.name', + type: 'keyword', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some host name', + }, + ]; + expect(output).toEqual(expected); + }); + test('it returns formatted entries when no nested entries exist', () => { const payloadIndexPattern = getMockIndexPattern(); const payloadItems: BuilderEntry[] = [ { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, { ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] }, ]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems, false); const field1: FieldSpec = { aggregatable: true, count: 0, @@ -1139,7 +1160,7 @@ describe('Exception builder helpers', () => { { ...payloadParent }, ]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems, false); const field1: FieldSpec = { aggregatable: true, count: 0, @@ -1313,7 +1334,8 @@ describe('Exception builder helpers', () => { payloadItem, 0, undefined, - undefined + undefined, + false ); const field: FieldSpec = { aggregatable: false, @@ -1338,6 +1360,95 @@ describe('Exception builder helpers', () => { expect(output).toEqual(expected); }); + test('it returns entry with field value undefined if "allowCustomFieldOptions" is "false" and no matching field found', () => { + const payloadIndexPattern: DataViewBase = { + ...getMockIndexPattern(), + fields: [ + ...fields, + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'machine.os.raw.text', + readFromDocValues: true, + scripted: false, + searchable: false, + type: 'string', + }, + ], + }; + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'custom.text', + value: 'some os', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined, + false + ); + const expected: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: 0, + field: undefined, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some os', + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with custom field value if "allowCustomFieldOptions" is "true" and no matching field found', () => { + const payloadIndexPattern: DataViewBase = { + ...getMockIndexPattern(), + fields: [ + ...fields, + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'machine.os.raw.text', + readFromDocValues: true, + scripted: false, + searchable: false, + type: 'string', + }, + ], + }; + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'custom.text', + value: 'some os', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined, + true + ); + const expected: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + name: 'custom.text', + type: 'keyword', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some os', + }; + expect(output).toEqual(expected); + }); + test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { const payloadIndexPattern = getMockIndexPattern(); const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' }; @@ -1351,7 +1462,8 @@ describe('Exception builder helpers', () => { payloadItem, 0, payloadParent, - 1 + 1, + false ); const field: FieldSpec = { aggregatable: false, @@ -1401,7 +1513,8 @@ describe('Exception builder helpers', () => { payloadItem, 0, undefined, - undefined + undefined, + false ); const field: FieldSpec = { aggregatable: true, @@ -1577,8 +1690,9 @@ describe('Exception builder helpers', () => { // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes // for context around the temporary `id` test('it correctly validates entries that include a temporary `id`', () => { - const output: Array = - filterExceptionItems([{ ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }]); }); @@ -1611,13 +1725,12 @@ describe('Exception builder helpers', () => { type: OperatorTypeEnum.MATCH, value: '', }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); @@ -1631,13 +1744,12 @@ describe('Exception builder helpers', () => { type: OperatorTypeEnum.MATCH, value: 'some value', }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); @@ -1651,13 +1763,12 @@ describe('Exception builder helpers', () => { type: OperatorTypeEnum.MATCH_ANY, value: ['some value'], }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); @@ -1669,13 +1780,12 @@ describe('Exception builder helpers', () => { field: '', type: OperatorTypeEnum.NESTED, }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); @@ -1687,13 +1797,12 @@ describe('Exception builder helpers', () => { field: 'host.name', type: OperatorTypeEnum.NESTED, }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([ { @@ -1713,27 +1822,134 @@ describe('Exception builder helpers', () => { field: 'host.name', type: OperatorTypeEnum.NESTED, }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); - test('it removes `temporaryId` from items', () => { + test('it removes `temporaryId` from "createExceptionListItemSchema" items', () => { const { meta, ...rest } = getNewExceptionItem({ listId: '123', + name: 'rule name', namespaceType: 'single', - ruleName: 'rule name', }); const exceptions = filterExceptionItems([{ ...rest, entries: [getEntryMatchMock()], meta }]); expect(exceptions).toEqual([{ ...rest, entries: [getEntryMatchMock()], meta: undefined }]); }); + + test('it removes `temporaryId` from "createRuleExceptionListItemSchema" items', () => { + const { meta, ...rest } = getNewExceptionItem({ + listId: undefined, + name: 'rule name', + namespaceType: undefined, + }); + const exceptions = filterExceptionItems([{ ...rest, entries: [getEntryMatchMock()], meta }]); + + expect(exceptions).toEqual([{ ...rest, entries: [getEntryMatchMock()], meta: undefined }]); + }); + }); + + describe('#getNewExceptionItem', () => { + it('returns new item with updated name', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: '123', + name: 'My Item Name', + namespaceType: 'single', + }); + + expect(rest.name).toEqual('My Item Name'); + }); + + it('returns new item with list_id if one is passed in', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: '123', + name: 'My Item Name', + namespaceType: 'single', + }); + + expect(rest).toEqual({ + comments: [], + description: 'Exception list item', + entries: [{ field: '', id: '123', operator: 'included', type: 'match', value: '' }], + item_id: undefined, + list_id: '123', + name: 'My Item Name', + namespace_type: 'single', + tags: [], + type: 'simple', + }); + }); + + it('returns new item without list_id if none is passed in', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: undefined, + name: 'My Item Name', + namespaceType: 'single', + }); + + expect(rest).toEqual({ + comments: [], + description: 'Exception list item', + entries: [{ field: '', id: '123', operator: 'included', type: 'match', value: '' }], + item_id: undefined, + list_id: undefined, + name: 'My Item Name', + namespace_type: 'single', + tags: [], + type: 'simple', + }); + }); + + it('returns new item with namespace_type if one is passed in', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: '123', + name: 'My Item Name', + namespaceType: 'single', + }); + + expect(rest).toEqual({ + comments: [], + description: 'Exception list item', + entries: [{ field: '', id: '123', operator: 'included', type: 'match', value: '' }], + item_id: undefined, + list_id: '123', + name: 'My Item Name', + namespace_type: 'single', + tags: [], + type: 'simple', + }); + }); + + it('returns new item without namespace_type if none is passed in', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: '123', + name: 'My Item Name', + namespaceType: undefined, + }); + + expect(rest).toEqual({ + comments: [], + description: 'Exception list item', + entries: [{ field: '', id: '123', operator: 'included', type: 'match', value: '' }], + item_id: undefined, + list_id: '123', + name: 'My Item Name', + namespace_type: undefined, + tags: [], + type: 'simple', + }); + }); }); describe('#getEntryValue', () => { diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts b/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts index 291ef7a420f0..ee7971e69c83 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts @@ -75,3 +75,11 @@ export const AND = i18n.translate('xpack.lists.exceptions.andDescription', { export const OR = i18n.translate('xpack.lists.exceptions.orDescription', { defaultMessage: 'OR', }); + +export const CUSTOM_COMBOBOX_OPTION_TEXT = i18n.translate( + 'xpack.lists.exceptions.comboBoxCustomOptionText', + { + defaultMessage: + 'Select a field from the list. If your field is not available, create a custom one.', + } +); diff --git a/x-pack/plugins/security_solution/common/ecs/rule/index.ts b/x-pack/plugins/security_solution/common/ecs/rule/index.ts index 073bb7db3a3e..c52a9253122d 100644 --- a/x-pack/plugins/security_solution/common/ecs/rule/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/rule/index.ts @@ -17,6 +17,7 @@ export interface RuleEcs { risk_score?: string[]; output_index?: string[]; description?: string[]; + exceptions_list?: string[]; from?: string[]; immutable?: boolean[]; index?: string[]; diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_flyout.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts similarity index 83% rename from x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_flyout.cy.ts rename to x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts index 952325ab0155..fcde59d0bd79 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_flyout.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts @@ -5,27 +5,32 @@ * 2.0. */ -import { getNewRule } from '../../objects/rule'; +import { getNewRule } from '../../../objects/rule'; -import { RULE_STATUS } from '../../screens/create_new_rule'; +import { RULE_STATUS } from '../../../screens/create_new_rule'; -import { createCustomRule } from '../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; -import { esArchiverLoad, esArchiverResetKibana, esArchiverUnload } from '../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../tasks/login'; +import { createCustomRule } from '../../../tasks/api_calls/rules'; +import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; +import { + esArchiverLoad, + esArchiverResetKibana, + esArchiverUnload, +} from '../../../tasks/es_archiver'; +import { login, visitWithoutDateRange } from '../../../tasks/login'; import { openExceptionFlyoutFromEmptyViewerPrompt, goToExceptionsTab, openEditException, -} from '../../tasks/rule_details'; +} from '../../../tasks/rule_details'; import { addExceptionEntryFieldMatchAnyValue, addExceptionEntryFieldValue, addExceptionEntryFieldValueOfItemX, addExceptionEntryFieldValueValue, addExceptionEntryOperatorValue, + addExceptionFlyoutItemName, closeExceptionBuilderFlyout, -} from '../../tasks/exceptions'; +} from '../../../tasks/exceptions'; import { ADD_AND_BTN, ADD_OR_BTN, @@ -34,7 +39,6 @@ import { FIELD_INPUT, LOADING_SPINNER, EXCEPTION_ITEM_CONTAINER, - ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN, EXCEPTION_FIELD_LIST, EXCEPTION_EDIT_FLYOUT_SAVE_BTN, EXCEPTION_FLYOUT_VERSION_CONFLICT, @@ -42,17 +46,17 @@ import { CONFIRM_BTN, VALUES_INPUT, EXCEPTION_FLYOUT_TITLE, -} from '../../screens/exceptions'; +} from '../../../screens/exceptions'; -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; -import { reload } from '../../tasks/common'; +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; +import { reload } from '../../../tasks/common'; import { createExceptionList, createExceptionListItem, updateExceptionListItem, deleteExceptionList, -} from '../../tasks/api_calls/exceptions'; -import { getExceptionList } from '../../objects/exception'; +} from '../../../tasks/api_calls/exceptions'; +import { getExceptionList } from '../../../objects/exception'; // NOTE: You might look at these tests and feel they're overkill, // but the exceptions flyout has a lot of logic making it difficult @@ -92,12 +96,11 @@ describe('Exceptions flyout', () => { }); it('Validates empty entry values correctly', () => { - cy.root() - .pipe(($el) => { - $el.find(ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN).trigger('click'); - return $el.find(ADD_AND_BTN); - }) - .should('be.visible'); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item name + addExceptionFlyoutItemName('My item name'); // add an entry with a value and submit button should enable addExceptionEntryFieldValue('agent.name', 0); @@ -120,13 +123,27 @@ describe('Exceptions flyout', () => { closeExceptionBuilderFlyout(); }); + it('Validates custom fields correctly', () => { + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + + // add an entry with a value and submit button should enable + addExceptionEntryFieldValue('blooberty', 0); + addExceptionEntryFieldValueValue('blah', 0); + cy.get(CONFIRM_BTN).should('be.enabled'); + + closeExceptionBuilderFlyout(); + }); + it('Does not overwrite values and-ed together', () => { - cy.root() - .pipe(($el) => { - $el.find(ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN).trigger('click'); - return $el.find(ADD_AND_BTN); - }) - .should('be.visible'); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item name + addExceptionFlyoutItemName('My item name'); // add multiple entries with invalid field values addExceptionEntryFieldValue('agent.name', 0); @@ -144,12 +161,12 @@ describe('Exceptions flyout', () => { }); it('Does not overwrite values or-ed together', () => { - cy.root() - .pipe(($el) => { - $el.find(ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN).trigger('click'); - return $el.find(ADD_AND_BTN); - }) - .should('be.visible'); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + // exception item 1 addExceptionEntryFieldValueOfItemX('agent.name', 0, 0); cy.get(ADD_AND_BTN).click(); @@ -265,19 +282,17 @@ describe('Exceptions flyout', () => { }); it('Contains custom index fields', () => { - cy.root() - .pipe(($el) => { - $el.find(ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN).trigger('click'); - return $el.find(ADD_AND_BTN); - }) - .should('be.visible'); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + cy.get(FIELD_INPUT).eq(0).click({ force: true }); cy.get(EXCEPTION_FIELD_LIST).contains('unique_value.test'); closeExceptionBuilderFlyout(); }); - describe('flyout errors', () => { + // TODO - Add back in error states into modal + describe.skip('flyout errors', () => { beforeEach(() => { // create exception item via api createExceptionListItem(getExceptionList().list_id, { diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/alerts_table_flow/add_exception.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/alerts_table_flow/add_exception.cy.ts index f1d6d2f1cc06..213ea64fc4ce 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/alerts_table_flow/add_exception.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/alerts_table_flow/add_exception.cy.ts @@ -5,98 +5,190 @@ * 2.0. */ -import { getException } from '../../../objects/exception'; +import { ROLES } from '../../../../common/test'; +import { getExceptionList, expectedExportedExceptionList } from '../../../objects/exception'; import { getNewRule } from '../../../objects/rule'; -import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../screens/alerts'; +import { createCustomRule } from '../../../tasks/api_calls/rules'; +import { login, visitWithoutDateRange, waitForPageWithoutDateRange } from '../../../tasks/login'; +import { EXCEPTIONS_URL } from '../../../urls/navigation'; import { - addExceptionFromFirstAlert, - goToClosedAlerts, - goToOpenedAlerts, -} from '../../../tasks/alerts'; -import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; + deleteExceptionListWithRuleReference, + deleteExceptionListWithoutRuleReference, + exportExceptionList, + searchForExceptionList, + waitForExceptionsTableToBeLoaded, + clearSearchSelection, +} from '../../../tasks/exceptions_table'; import { - esArchiverLoad, - esArchiverUnload, - esArchiverResetKibana, -} from '../../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../../tasks/login'; -import { - addsException, - goToAlertsTab, - goToExceptionsTab, - removeException, - waitForTheRuleToBeExecuted, -} from '../../../tasks/rule_details'; - -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; -import { deleteAlertsAndRules } from '../../../tasks/common'; + EXCEPTIONS_TABLE_DELETE_BTN, + EXCEPTIONS_TABLE_LIST_NAME, + EXCEPTIONS_TABLE_SHOWING_LISTS, +} from '../../../screens/exceptions'; +import { createExceptionList } from '../../../tasks/api_calls/exceptions'; +import { esArchiverResetKibana } from '../../../tasks/es_archiver'; +import { TOASTER } from '../../../screens/alerts_detection_rules'; -describe('Adds rule exception from alerts flow', () => { - const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; +const getExceptionList1 = () => ({ + ...getExceptionList(), + name: 'Test a new list 1', + list_id: 'exception_list_1', +}); +const getExceptionList2 = () => ({ + ...getExceptionList(), + name: 'Test list 2', + list_id: 'exception_list_2', +}); +describe('Exceptions Table', () => { before(() => { esArchiverResetKibana(); - esArchiverLoad('exceptions'); login(); - }); - beforeEach(() => { - deleteAlertsAndRules(); - createCustomRuleEnabled( - { + // Create exception list associated with a rule + createExceptionList(getExceptionList2(), getExceptionList2().list_id).then((response) => + createCustomRule({ ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, - }, - 'rule_testing', - '1s' + exceptionLists: [ + { + id: response.body.id, + list_id: getExceptionList2().list_id, + type: getExceptionList2().type, + namespace_type: getExceptionList2().namespace_type, + }, + ], + }) ); - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); + + // Create exception list not used by any rules + createExceptionList(getExceptionList1(), getExceptionList1().list_id).as( + 'exceptionListResponse' + ); + + visitWithoutDateRange(EXCEPTIONS_URL); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); + }); + + it('Exports exception list', function () { + cy.intercept(/(\/api\/exception_lists\/_export)/).as('export'); + + visitWithoutDateRange(EXCEPTIONS_URL); + waitForExceptionsTableToBeLoaded(); + exportExceptionList(); + + cy.wait('@export').then(({ response }) => { + cy.wrap(response?.body).should( + 'eql', + expectedExportedExceptionList(this.exceptionListResponse) + ); + + cy.get(TOASTER).should('have.text', 'Exception list export success'); + }); + }); + + it('Filters exception lists on search', () => { + visitWithoutDateRange(EXCEPTIONS_URL); + waitForExceptionsTableToBeLoaded(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); + + // Single word search + searchForExceptionList('Endpoint'); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'Endpoint Security Exception List'); + + // Multi word search + clearSearchSelection(); + searchForExceptionList('test'); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(1).should('have.text', 'Test list 2'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(0).should('have.text', 'Test a new list 1'); + + // Exact phrase search + clearSearchSelection(); + searchForExceptionList(`"${getExceptionList1().name}"`); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', getExceptionList1().name); + + // Field search + clearSearchSelection(); + searchForExceptionList('list_id:endpoint_list'); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'Endpoint Security Exception List'); + + clearSearchSelection(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); + }); + + it('Deletes exception list without rule reference', () => { + visitWithoutDateRange(EXCEPTIONS_URL); + waitForExceptionsTableToBeLoaded(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); + + deleteExceptionListWithoutRuleReference(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2'); }); - afterEach(() => { - esArchiverUnload('exceptions_2'); + it('Deletes exception list with rule reference', () => { + waitForPageWithoutDateRange(EXCEPTIONS_URL); + waitForExceptionsTableToBeLoaded(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2'); + + deleteExceptionListWithRuleReference(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); }); +}); + +describe('Exceptions Table - read only', () => { + before(() => { + // First we login as a privileged user to create exception list + esArchiverResetKibana(); + login(ROLES.platform_engineer); + visitWithoutDateRange(EXCEPTIONS_URL, ROLES.platform_engineer); + createExceptionList(getExceptionList(), getExceptionList().list_id); + + // Then we login as read-only user to test. + login(ROLES.reader); + visitWithoutDateRange(EXCEPTIONS_URL, ROLES.reader); + waitForExceptionsTableToBeLoaded(); - after(() => { - esArchiverUnload('exceptions'); + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); }); - it('Creates an exception from an alert and deletes it', () => { - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS); - // Create an exception from the alerts actions menu that matches - // the existing alert - addExceptionFromFirstAlert(); - addsException(getException()); - - // Alerts table should now be empty from having added exception and closed - // matching alert - cy.get(EMPTY_ALERT_TABLE).should('exist'); - - // Closed alert should appear in table - goToClosedAlerts(); - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); - - // Remove the exception and load an event that would have matched that exception - // to show that said exception now starts to show up again - goToExceptionsTab(); - removeException(); - esArchiverLoad('exceptions_2'); - goToAlertsTab(); - goToOpenedAlerts(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); + it('Delete icon is not shown', () => { + cy.get(EXCEPTIONS_TABLE_DELETE_BTN).should('not.exist'); }); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_table.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts similarity index 91% rename from x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_table.cy.ts rename to x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts index b037c4f6d62c..213ea64fc4ce 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_table.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { ROLES } from '../../../common/test'; -import { getExceptionList, expectedExportedExceptionList } from '../../objects/exception'; -import { getNewRule } from '../../objects/rule'; +import { ROLES } from '../../../../common/test'; +import { getExceptionList, expectedExportedExceptionList } from '../../../objects/exception'; +import { getNewRule } from '../../../objects/rule'; -import { createCustomRule } from '../../tasks/api_calls/rules'; -import { login, visitWithoutDateRange, waitForPageWithoutDateRange } from '../../tasks/login'; +import { createCustomRule } from '../../../tasks/api_calls/rules'; +import { login, visitWithoutDateRange, waitForPageWithoutDateRange } from '../../../tasks/login'; -import { EXCEPTIONS_URL } from '../../urls/navigation'; +import { EXCEPTIONS_URL } from '../../../urls/navigation'; import { deleteExceptionListWithRuleReference, deleteExceptionListWithoutRuleReference, @@ -20,15 +20,15 @@ import { searchForExceptionList, waitForExceptionsTableToBeLoaded, clearSearchSelection, -} from '../../tasks/exceptions_table'; +} from '../../../tasks/exceptions_table'; import { EXCEPTIONS_TABLE_DELETE_BTN, EXCEPTIONS_TABLE_LIST_NAME, EXCEPTIONS_TABLE_SHOWING_LISTS, -} from '../../screens/exceptions'; -import { createExceptionList } from '../../tasks/api_calls/exceptions'; -import { esArchiverResetKibana } from '../../tasks/es_archiver'; -import { TOASTER } from '../../screens/alerts_detection_rules'; +} from '../../../screens/exceptions'; +import { createExceptionList } from '../../../tasks/api_calls/exceptions'; +import { esArchiverResetKibana } from '../../../tasks/es_archiver'; +import { TOASTER } from '../../../screens/alerts_detection_rules'; const getExceptionList1 = () => ({ ...getExceptionList(), diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts new file mode 100644 index 000000000000..da975710c7f3 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getNewRule } from '../../../objects/rule'; + +import { createCustomRule } from '../../../tasks/api_calls/rules'; +import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; +import { + esArchiverLoad, + esArchiverResetKibana, + esArchiverUnload, +} from '../../../tasks/es_archiver'; +import { login, visitWithoutDateRange } from '../../../tasks/login'; +import { + goToEndpointExceptionsTab, + openEditException, + openExceptionFlyoutFromEmptyViewerPrompt, + searchForExceptionItem, +} from '../../../tasks/rule_details'; +import { + addExceptionConditions, + addExceptionFlyoutItemName, + editException, + editExceptionFlyoutItemName, + selectOs, + submitEditedExceptionItem, + submitNewExceptionItem, +} from '../../../tasks/exceptions'; + +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; +import { deleteAlertsAndRules } from '../../../tasks/common'; +import { + NO_EXCEPTIONS_EXIST_PROMPT, + EXCEPTION_ITEM_VIEWER_CONTAINER, + NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT, + CLOSE_ALERTS_CHECKBOX, + CONFIRM_BTN, + ADD_TO_RULE_OR_LIST_SECTION, + CLOSE_SINGLE_ALERT_CHECKBOX, + EXCEPTION_ITEM_CONTAINER, + VALUES_INPUT, + FIELD_INPUT, + EXCEPTION_CARD_ITEM_NAME, + EXCEPTION_CARD_ITEM_CONDITIONS, +} from '../../../screens/exceptions'; +import { createEndpointExceptionList } from '../../../tasks/api_calls/exceptions'; + +describe('Add endpoint exception from rule details', () => { + const ITEM_NAME = 'Sample Exception List Item'; + + before(() => { + esArchiverResetKibana(); + esArchiverLoad('auditbeat'); + login(); + }); + + before(() => { + deleteAlertsAndRules(); + // create rule with exception + createEndpointExceptionList().then((response) => { + createCustomRule( + { + ...getNewRule(), + customQuery: 'event.code:*', + dataSource: { index: ['auditbeat*'], type: 'indexPatterns' }, + exceptionLists: [ + { + id: response.body.id, + list_id: response.body.list_id, + type: response.body.type, + namespace_type: response.body.namespace_type, + }, + ], + }, + '2' + ); + }); + + visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); + goToRuleDetails(); + goToEndpointExceptionsTab(); + }); + + after(() => { + esArchiverUnload('auditbeat'); + }); + + it('creates an exception item', () => { + // when no exceptions exist, empty component shows with action to add exception + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); + + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // for endpoint exceptions, must specify OS + selectOs('windows'); + + // add exception item conditions + addExceptionConditions({ + field: 'event.code', + operator: 'is', + values: ['foo'], + }); + + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + + // add exception item name + addExceptionFlyoutItemName(ITEM_NAME); + + // Option to add to rule or add to list should NOT appear + cy.get(ADD_TO_RULE_OR_LIST_SECTION).should('not.exist'); + + // not testing close alert functionality here, just ensuring that the options appear as expected + cy.get(CLOSE_SINGLE_ALERT_CHECKBOX).should('not.exist'); + cy.get(CLOSE_ALERTS_CHECKBOX).should('exist'); + + // submit + submitNewExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + }); + + it('edits an endpoint exception item', () => { + const NEW_ITEM_NAME = 'Exception item-EDITED'; + const ITEM_FIELD = 'event.code'; + const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; + + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ` ${ITEM_FIELD}IS foo`); + + // open edit exception modal + openEditException(); + + // edit exception item name + editExceptionFlyoutItemName(NEW_ITEM_NAME); + + // check that the existing item's field is being populated + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); + cy.get(VALUES_INPUT).should('have.text', 'foo'); + + // edit conditions + editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); + + // submit + submitEditedExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // check that updates stuck + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', NEW_ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' agent.nameIS foo'); + }); + + it('allows user to search for items', () => { + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // can search for an exception value + searchForExceptionItem('foo'); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // displays empty search result view if no matches found + searchForExceptionItem('abc'); + + // new exception item displays + cy.get(NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts new file mode 100644 index 000000000000..64a2e14bbf61 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts @@ -0,0 +1,336 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getException, getExceptionList } from '../../../objects/exception'; +import { getNewRule } from '../../../objects/rule'; + +import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../screens/alerts'; +import { createCustomRule, createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; +import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; +import { goToClosedAlerts, goToOpenedAlerts } from '../../../tasks/alerts'; +import { + esArchiverLoad, + esArchiverUnload, + esArchiverResetKibana, +} from '../../../tasks/es_archiver'; +import { login, visitWithoutDateRange } from '../../../tasks/login'; +import { + addExceptionFlyoutFromViewerHeader, + goToAlertsTab, + goToExceptionsTab, + openEditException, + openExceptionFlyoutFromEmptyViewerPrompt, + removeException, + searchForExceptionItem, + waitForTheRuleToBeExecuted, +} from '../../../tasks/rule_details'; +import { + addExceptionConditions, + addExceptionFlyoutItemName, + editException, + editExceptionFlyoutItemName, + selectAddToRuleRadio, + selectBulkCloseAlerts, + selectSharedListToAddExceptionTo, + submitEditedExceptionItem, + submitNewExceptionItem, +} from '../../../tasks/exceptions'; +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; +import { deleteAlertsAndRules } from '../../../tasks/common'; +import { + NO_EXCEPTIONS_EXIST_PROMPT, + EXCEPTION_ITEM_VIEWER_CONTAINER, + NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT, + CLOSE_ALERTS_CHECKBOX, + CONFIRM_BTN, + ADD_TO_SHARED_LIST_RADIO_INPUT, + EXCEPTION_ITEM_CONTAINER, + FIELD_INPUT, + VALUES_MATCH_ANY_INPUT, + EXCEPTION_CARD_ITEM_NAME, + EXCEPTION_CARD_ITEM_CONDITIONS, +} from '../../../screens/exceptions'; +import { + createExceptionList, + createExceptionListItem, + deleteExceptionList, +} from '../../../tasks/api_calls/exceptions'; +import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; + +describe('Add/edit exception from rule details', () => { + const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; + const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; + const ITEM_FIELD = 'unique_value.test'; + + before(() => { + esArchiverResetKibana(); + esArchiverLoad('exceptions'); + login(); + }); + + after(() => { + esArchiverUnload('exceptions'); + }); + + describe('existing list and items', () => { + const exceptionList = getExceptionList(); + beforeEach(() => { + deleteAlertsAndRules(); + deleteExceptionList(exceptionList.list_id, exceptionList.namespace_type); + // create rule with exceptions + createExceptionList(exceptionList, exceptionList.list_id).then((response) => { + createCustomRule( + { + ...getNewRule(), + customQuery: 'agent.name:*', + dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, + exceptionLists: [ + { + id: response.body.id, + list_id: exceptionList.list_id, + type: exceptionList.type, + namespace_type: exceptionList.namespace_type, + }, + ], + }, + '2' + ); + createExceptionListItem(exceptionList.list_id, { + list_id: exceptionList.list_id, + item_id: 'simple_list_item', + tags: [], + type: 'simple', + description: 'Test exception item 2', + name: 'Sample Exception List Item 2', + namespace_type: 'single', + entries: [ + { + field: ITEM_FIELD, + operator: 'included', + type: 'match_any', + value: ['foo'], + }, + ], + }); + }); + + visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); + goToRuleDetails(); + goToExceptionsTab(); + }); + + it('Edits an exception item', () => { + const NEW_ITEM_NAME = 'Exception item-EDITED'; + const ITEM_NAME = 'Sample Exception List Item 2'; + + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' unique_value.testis one of foo'); + + // open edit exception modal + openEditException(); + + // edit exception item name + editExceptionFlyoutItemName(NEW_ITEM_NAME); + + // check that the existing item's field is being populated + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', ITEM_FIELD); + cy.get(VALUES_MATCH_ANY_INPUT).should('have.text', 'foo'); + + // edit conditions + editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); + + // submit + submitEditedExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // check that updates stuck + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', NEW_ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' agent.nameIS foo'); + }); + + describe('rule with existing shared exceptions', () => { + it('Creates an exception item to add to shared list', () => { + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + + // open add exception modal + addExceptionFlyoutFromViewerHeader(); + + // add exception item conditions + addExceptionConditions(getException()); + + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + + // select to add exception item to a shared list + selectSharedListToAddExceptionTo(1); + + // not testing close alert functionality here, just ensuring that the options appear as expected + cy.get(CLOSE_ALERTS_CHECKBOX).should('exist'); + cy.get(CLOSE_ALERTS_CHECKBOX).should('not.have.attr', 'disabled'); + + // submit + submitNewExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 2); + }); + + it('Creates an exception item to add to rule only', () => { + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + + // open add exception modal + addExceptionFlyoutFromViewerHeader(); + + // add exception item conditions + addExceptionConditions(getException()); + + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + + // select to add exception item to rule only + selectAddToRuleRadio(); + + // not testing close alert functionality here, just ensuring that the options appear as expected + cy.get(CLOSE_ALERTS_CHECKBOX).should('exist'); + cy.get(CLOSE_ALERTS_CHECKBOX).should('not.have.attr', 'disabled'); + + // submit + submitNewExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 2); + }); + + // Trying to figure out with EUI why the search won't trigger + it('Can search for items', () => { + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + + // can search for an exception value + searchForExceptionItem('foo'); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // displays empty search result view if no matches found + searchForExceptionItem('abc'); + + // new exception item displays + cy.get(NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT).should('exist'); + }); + }); + }); + + describe('rule without existing exceptions', () => { + beforeEach(() => { + deleteAlertsAndRules(); + createCustomRuleEnabled( + { + ...getNewRule(), + customQuery: 'agent.name:*', + dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, + }, + 'rule_testing', + '1s' + ); + visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); + goToRuleDetails(); + goToExceptionsTab(); + }); + + afterEach(() => { + esArchiverUnload('exceptions_2'); + }); + + it('Cannot create an item to add to rule but not shared list as rule has no lists attached', () => { + // when no exceptions exist, empty component shows with action to add exception + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); + + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item conditions + addExceptionConditions({ + field: 'agent.name', + operator: 'is', + values: ['foo'], + }); + + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + + // select to add exception item to rule only + selectAddToRuleRadio(); + + // Check that add to shared list is disabled, should be unless + // rule has shared lists attached to it already + cy.get(ADD_TO_SHARED_LIST_RADIO_INPUT).should('have.attr', 'disabled'); + + // Close matching alerts + selectBulkCloseAlerts(); + + // submit + submitNewExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // Alerts table should now be empty from having added exception and closed + // matching alert + goToAlertsTab(); + cy.get(EMPTY_ALERT_TABLE).should('exist'); + + // Closed alert should appear in table + goToClosedAlerts(); + cy.get(ALERTS_COUNT).should('exist'); + cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); + + // Remove the exception and load an event that would have matched that exception + // to show that said exception now starts to show up again + goToExceptionsTab(); + + // when removing exception and again, no more exist, empty screen shows again + removeException(); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); + + // load more docs + esArchiverLoad('exceptions_2'); + + // now that there are no more exceptions, the docs should match and populate alerts + goToAlertsTab(); + goToOpenedAlerts(); + waitForTheRuleToBeExecuted(); + waitForAlertsToPopulate(); + + cy.get(ALERTS_COUNT).should('exist'); + cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception_data_view.spect.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts similarity index 63% rename from x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception_data_view.spect.ts rename to x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts index 05b21abe5256..f17a5e4221a8 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception_data_view.spect.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts @@ -10,6 +10,11 @@ import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../scre import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; import { goToClosedAlerts, goToOpenedAlerts } from '../../../tasks/alerts'; +import { + editException, + editExceptionFlyoutItemName, + submitEditedExceptionItem, +} from '../../../tasks/exceptions'; import { esArchiverLoad, esArchiverUnload, @@ -20,6 +25,7 @@ import { addFirstExceptionFromRuleDetails, goToAlertsTab, goToExceptionsTab, + openEditException, removeException, waitForTheRuleToBeExecuted, } from '../../../tasks/rule_details'; @@ -29,11 +35,17 @@ import { postDataView, deleteAlertsAndRules } from '../../../tasks/common'; import { NO_EXCEPTIONS_EXIST_PROMPT, EXCEPTION_ITEM_VIEWER_CONTAINER, + EXCEPTION_CARD_ITEM_NAME, + EXCEPTION_CARD_ITEM_CONDITIONS, + EXCEPTION_ITEM_CONTAINER, + FIELD_INPUT, + VALUES_INPUT, } from '../../../screens/exceptions'; import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; describe('Add exception using data views from rule details', () => { const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; + const ITEM_NAME = 'Sample Exception List Item'; before(() => { esArchiverResetKibana(); @@ -66,17 +78,20 @@ describe('Add exception using data views from rule details', () => { esArchiverUnload('exceptions_2'); }); - it('Creates an exception item when none exist', () => { + it('Creates an exception item', () => { // when no exceptions exist, empty component shows with action to add exception cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); // clicks prompt button to add first exception that will also select to close // all matching alerts - addFirstExceptionFromRuleDetails({ - field: 'agent.name', - operator: 'is', - values: ['foo'], - }); + addFirstExceptionFromRuleDetails( + { + field: 'agent.name', + operator: 'is', + values: ['foo'], + }, + ITEM_NAME + ); // new exception item displays cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); @@ -111,4 +126,49 @@ describe('Add exception using data views from rule details', () => { cy.get(ALERTS_COUNT).should('exist'); cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); }); + + it('Edits an exception item', () => { + const NEW_ITEM_NAME = 'Exception item-EDITED'; + const ITEM_FIELD = 'unique_value.test'; + const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; + + // add item to edit + addFirstExceptionFromRuleDetails( + { + field: ITEM_FIELD, + operator: 'is', + values: ['foo'], + }, + ITEM_NAME + ); + + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' unique_value.testIS foo'); + + // open edit exception modal + openEditException(); + + // edit exception item name + editExceptionFlyoutItemName(NEW_ITEM_NAME); + + // check that the existing item's field is being populated + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); + cy.get(VALUES_INPUT).should('have.text', 'foo'); + + // edit conditions + editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); + + // submit + submitEditedExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // check that updates stuck + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', NEW_ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' agent.nameIS foo'); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception.spec.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception.spec.ts deleted file mode 100644 index 3ea14d8b3ffd..000000000000 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception.spec.ts +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getException, getExceptionList } from '../../../objects/exception'; -import { getNewRule } from '../../../objects/rule'; - -import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../screens/alerts'; -import { createCustomRule, createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { goToClosedAlerts, goToOpenedAlerts } from '../../../tasks/alerts'; -import { - esArchiverLoad, - esArchiverUnload, - esArchiverResetKibana, -} from '../../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../../tasks/login'; -import { - addExceptionFromRuleDetails, - addFirstExceptionFromRuleDetails, - goToAlertsTab, - goToExceptionsTab, - removeException, - searchForExceptionItem, - waitForTheRuleToBeExecuted, -} from '../../../tasks/rule_details'; - -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; -import { deleteAlertsAndRules } from '../../../tasks/common'; -import { - NO_EXCEPTIONS_EXIST_PROMPT, - EXCEPTION_ITEM_VIEWER_CONTAINER, - NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT, -} from '../../../screens/exceptions'; -import { - createExceptionList, - createExceptionListItem, - deleteExceptionList, -} from '../../../tasks/api_calls/exceptions'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; - -describe('Add exception from rule details', () => { - const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; - - before(() => { - esArchiverResetKibana(); - esArchiverLoad('exceptions'); - login(); - }); - - after(() => { - esArchiverUnload('exceptions'); - }); - - describe('rule with existing exceptions', () => { - const exceptionList = getExceptionList(); - beforeEach(() => { - deleteAlertsAndRules(); - deleteExceptionList(exceptionList.list_id, exceptionList.namespace_type); - // create rule with exceptions - createExceptionList(exceptionList, exceptionList.list_id).then((response) => { - createCustomRule( - { - ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, - exceptionLists: [ - { - id: response.body.id, - list_id: exceptionList.list_id, - type: exceptionList.type, - namespace_type: exceptionList.namespace_type, - }, - ], - }, - '2' - ); - createExceptionListItem(exceptionList.list_id, { - list_id: exceptionList.list_id, - item_id: 'simple_list_item', - tags: [], - type: 'simple', - description: 'Test exception item', - name: 'Sample Exception List Item', - namespace_type: 'single', - entries: [ - { - field: 'user.name', - operator: 'included', - type: 'match_any', - value: ['bar'], - }, - ], - }); - createExceptionListItem(exceptionList.list_id, { - list_id: exceptionList.list_id, - item_id: 'simple_list_item_2', - tags: [], - type: 'simple', - description: 'Test exception item 2', - name: 'Sample Exception List Item 2', - namespace_type: 'single', - entries: [ - { - field: 'unique_value.test', - operator: 'included', - type: 'match_any', - value: ['foo'], - }, - ], - }); - }); - - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - goToExceptionsTab(); - }); - - it('Creates an exception item', () => { - // displays existing exception items - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 2); - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); - - // clicks prompt button to add a new exception item - addExceptionFromRuleDetails(getException()); - - // new exception item displays - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 3); - }); - - // Trying to figure out with EUI why the search won't trigger - it('Can search for items', () => { - // displays existing exception items - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 2); - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); - - // can search for an exception value - searchForExceptionItem('foo'); - - // new exception item displays - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - // displays empty search result view if no matches found - searchForExceptionItem('abc'); - - // new exception item displays - cy.get(NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT).should('exist'); - }); - }); - - describe('rule without existing exceptions', () => { - beforeEach(() => { - deleteAlertsAndRules(); - createCustomRuleEnabled( - { - ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, - }, - 'rule_testing', - '1s' - ); - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - goToExceptionsTab(); - }); - - afterEach(() => { - esArchiverUnload('exceptions_2'); - }); - - it('Creates an exception item when none exist', () => { - // when no exceptions exist, empty component shows with action to add exception - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); - - // clicks prompt button to add first exception that will also select to close - // all matching alerts - addFirstExceptionFromRuleDetails({ - field: 'agent.name', - operator: 'is', - values: ['foo'], - }); - - // new exception item displays - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - // Alerts table should now be empty from having added exception and closed - // matching alert - goToAlertsTab(); - cy.get(EMPTY_ALERT_TABLE).should('exist'); - - // Closed alert should appear in table - goToClosedAlerts(); - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); - - // Remove the exception and load an event that would have matched that exception - // to show that said exception now starts to show up again - goToExceptionsTab(); - - // when removing exception and again, no more exist, empty screen shows again - removeException(); - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); - - // load more docs - esArchiverLoad('exceptions_2'); - - // now that there are no more exceptions, the docs should match and populate alerts - goToAlertsTab(); - goToOpenedAlerts(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception.spec.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception.spec.ts deleted file mode 100644 index ec44e76d09ed..000000000000 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getExceptionList } from '../../../objects/exception'; -import { getNewRule } from '../../../objects/rule'; - -import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { goToOpenedAlerts } from '../../../tasks/alerts'; -import { - esArchiverLoad, - esArchiverUnload, - esArchiverResetKibana, -} from '../../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../../tasks/login'; -import { - goToExceptionsTab, - waitForTheRuleToBeExecuted, - openEditException, - goToAlertsTab, -} from '../../../tasks/rule_details'; - -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; -import { deleteAlertsAndRules } from '../../../tasks/common'; -import { - EXCEPTION_ITEM_VIEWER_CONTAINER, - EXCEPTION_ITEM_CONTAINER, - FIELD_INPUT, -} from '../../../screens/exceptions'; -import { - createExceptionList, - createExceptionListItem, - deleteExceptionList, -} from '../../../tasks/api_calls/exceptions'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; -import { editException } from '../../../tasks/exceptions'; -import { ALERTS_COUNT, NUMBER_OF_ALERTS } from '../../../screens/alerts'; - -describe('Edit exception from rule details', () => { - const exceptionList = getExceptionList(); - const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; - const ITEM_FIELD = 'unique_value.test'; - const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; - - before(() => { - esArchiverResetKibana(); - esArchiverLoad('exceptions'); - login(); - }); - - after(() => { - esArchiverUnload('exceptions'); - }); - - beforeEach(() => { - deleteAlertsAndRules(); - deleteExceptionList(exceptionList.list_id, exceptionList.namespace_type); - // create rule with exceptions - createExceptionList(exceptionList, exceptionList.list_id).then((response) => { - createCustomRuleEnabled( - { - ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, - exceptionLists: [ - { - id: response.body.id, - list_id: exceptionList.list_id, - type: exceptionList.type, - namespace_type: exceptionList.namespace_type, - }, - ], - }, - '2', - '2s' - ); - createExceptionListItem(exceptionList.list_id, { - list_id: exceptionList.list_id, - item_id: 'simple_list_item', - tags: [], - type: 'simple', - description: 'Test exception item', - name: 'Sample Exception List Item', - namespace_type: 'single', - entries: [ - { - field: ITEM_FIELD, - operator: 'included', - type: 'match_any', - value: ['bar'], - }, - ], - }); - }); - - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - goToExceptionsTab(); - }); - - afterEach(() => { - esArchiverUnload('exceptions_2'); - }); - - it('Edits an exception item', () => { - // displays existing exception item - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - openEditException(); - - // check that the existing item's field is being populated - cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); - - // check that you can select a different field - editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - // Alerts table should still show single alert - goToAlertsTab(); - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); - - // load more docs - esArchiverLoad('exceptions_2'); - - // now that 2 more docs have been added, one should match the edited exception - goToAlertsTab(); - goToOpenedAlerts(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(2); - - // there should be 2 alerts, one is the original alert and the second is for the newly - // matching doc - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); - }); -}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception_data_view.spec.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception_data_view.spec.ts deleted file mode 100644 index 587486d99a06..000000000000 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception_data_view.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getExceptionList } from '../../../objects/exception'; -import { getNewRule } from '../../../objects/rule'; - -import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { goToOpenedAlerts } from '../../../tasks/alerts'; -import { - esArchiverLoad, - esArchiverUnload, - esArchiverResetKibana, -} from '../../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../../tasks/login'; -import { - goToExceptionsTab, - waitForTheRuleToBeExecuted, - openEditException, - goToAlertsTab, -} from '../../../tasks/rule_details'; - -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; -import { postDataView, deleteAlertsAndRules } from '../../../tasks/common'; -import { - EXCEPTION_ITEM_VIEWER_CONTAINER, - EXCEPTION_ITEM_CONTAINER, - FIELD_INPUT, -} from '../../../screens/exceptions'; -import { - createExceptionList, - createExceptionListItem, - deleteExceptionList, -} from '../../../tasks/api_calls/exceptions'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; -import { editException } from '../../../tasks/exceptions'; -import { ALERTS_COUNT, NUMBER_OF_ALERTS } from '../../../screens/alerts'; - -describe('Edit exception using data views from rule details', () => { - const exceptionList = getExceptionList(); - const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; - const ITEM_FIELD = 'unique_value.test'; - const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; - - before(() => { - esArchiverResetKibana(); - esArchiverLoad('exceptions'); - login(); - postDataView('exceptions-*'); - }); - - after(() => { - esArchiverUnload('exceptions'); - }); - - beforeEach(() => { - deleteAlertsAndRules(); - deleteExceptionList(exceptionList.list_id, exceptionList.namespace_type); - // create rule with exceptions - createExceptionList(exceptionList, exceptionList.list_id).then((response) => { - createCustomRuleEnabled( - { - ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { dataView: 'exceptions-*', type: 'dataView' }, - exceptionLists: [ - { - id: response.body.id, - list_id: exceptionList.list_id, - type: exceptionList.type, - namespace_type: exceptionList.namespace_type, - }, - ], - }, - '2', - '2s' - ); - createExceptionListItem(exceptionList.list_id, { - list_id: exceptionList.list_id, - item_id: 'simple_list_item', - tags: [], - type: 'simple', - description: 'Test exception item', - name: 'Sample Exception List Item', - namespace_type: 'single', - entries: [ - { - field: ITEM_FIELD, - operator: 'included', - type: 'match_any', - value: ['bar'], - }, - ], - }); - }); - - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - goToExceptionsTab(); - }); - - afterEach(() => { - esArchiverUnload('exceptions_2'); - }); - - it('Edits an exception item', () => { - // displays existing exception item - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - openEditException(); - - // check that the existing item's field is being populated - cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); - - // check that you can select a different field - editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - // Alerts table should still show single alert - goToAlertsTab(); - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); - - // load more docs - esArchiverLoad('exceptions_2'); - - // now that 2 more docs have been added, one should match the edited exception - goToAlertsTab(); - goToOpenedAlerts(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(2); - - // there should be 2 alerts, one is the original alert and the second is for the newly - // matching doc - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); - }); -}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/read_only_view.spect.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/read_only_view.cy.ts similarity index 100% rename from x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/read_only_view.spect.ts rename to x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/read_only_view.cy.ts diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index 2ceeaac0e8ca..2434b713a645 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -7,6 +7,8 @@ export const ADD_EXCEPTION_BTN = '[data-test-subj="add-exception-menu-item"]'; +export const ADD_ENDPOINT_EXCEPTION_BTN = '[data-test-subj="add-endpoint-exception-menu-item"]'; + export const ALERT_COUNT_TABLE_FIRST_ROW_COUNT = '[data-test-subj="alertsCountTable"] tr:nth-child(1) td:nth-child(2) .euiTableCellContent__text'; @@ -22,6 +24,8 @@ export const ALERT_SEVERITY = '[data-test-subj="formatted-field-kibana.alert.sev export const ALERT_DATA_GRID = '[data-test-subj="euiDataGridBody"]'; +export const ALERTS = '[data-test-subj="events-viewer-panel"][data-test-subj="event"]'; + export const ALERTS_COUNT = '[data-test-subj="events-viewer-panel"] [data-test-subj="server-side-event-count"]'; @@ -42,6 +46,10 @@ export const EMPTY_ALERT_TABLE = '[data-test-subj="tGridEmptyState"]'; export const EXPAND_ALERT_BTN = '[data-test-subj="expand-event"]'; +export const TAKE_ACTION_BTN = '[data-test-subj="take-action-dropdown-btn"]'; + +export const TAKE_ACTION_MENU = '[data-test-subj="takeActionPanelMenu"]'; + export const CLOSE_FLYOUT = '[data-test-subj="euiFlyoutCloseButton"]'; export const GROUP_BY_TOP_INPUT = '[data-test-subj="groupByTop"] [data-test-subj="comboBoxInput"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts index 1ca8ded94630..bf97d3e2e203 100644 --- a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts @@ -5,10 +5,11 @@ * 2.0. */ -export const CLOSE_ALERTS_CHECKBOX = - '[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]'; +export const CLOSE_ALERTS_CHECKBOX = '[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]'; -export const CONFIRM_BTN = '[data-test-subj="add-exception-confirm-button"]'; +export const CLOSE_SINGLE_ALERT_CHECKBOX = '[data-test-subj="closeAlertOnAddExceptionCheckbox"]'; + +export const CONFIRM_BTN = '[data-test-subj="addExceptionConfirmButton"]'; export const FIELD_INPUT = '[data-test-subj="fieldAutocompleteComboBox"] [data-test-subj="comboBoxInput"]'; @@ -57,9 +58,9 @@ export const EXCEPTION_ITEM_CONTAINER = '[data-test-subj="exceptionEntriesContai export const EXCEPTION_FIELD_LIST = '[data-test-subj="comboBoxOptionsList fieldAutocompleteComboBox-optionsList"]'; -export const EXCEPTION_FLYOUT_TITLE = '[data-test-subj="exception-flyout-title"]'; +export const EXCEPTION_FLYOUT_TITLE = '[data-test-subj="exceptionFlyoutTitle"]'; -export const EXCEPTION_EDIT_FLYOUT_SAVE_BTN = '[data-test-subj="edit-exception-confirm-button"]'; +export const EXCEPTION_EDIT_FLYOUT_SAVE_BTN = '[data-test-subj="editExceptionConfirmButton"]'; export const EXCEPTION_FLYOUT_VERSION_CONFLICT = '[data-test-subj="exceptionsFlyoutVersionConflict"]'; @@ -68,7 +69,7 @@ export const EXCEPTION_FLYOUT_LIST_DELETED_ERROR = '[data-test-subj="errorCallou // Exceptions all items view export const NO_EXCEPTIONS_EXIST_PROMPT = - '[data-test-subj="exceptionItemViewerEmptyPrompts-empty-detection"]'; + '[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]'; export const ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN = '[data-test-subj="exceptionsEmptyPromptButton"]'; @@ -82,3 +83,25 @@ export const NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT = '[data-test-subj="exceptionItemViewerEmptyPrompts-emptySearch"]'; export const EXCEPTION_ITEM_VIEWER_SEARCH = 'input[data-test-subj="exceptionsViewerSearchBar"]'; + +export const EXCEPTION_CARD_ITEM_NAME = '[data-test-subj="exceptionItemCardHeader-title"]'; + +export const EXCEPTION_CARD_ITEM_CONDITIONS = + '[data-test-subj="exceptionItemCardConditions-condition"]'; + +// Exception flyout components +export const EXCEPTION_ITEM_NAME_INPUT = 'input[data-test-subj="exceptionFlyoutNameInput"]'; + +export const ADD_TO_SHARED_LIST_RADIO_LABEL = '[data-test-subj="addToListsRadioOption"] label'; + +export const ADD_TO_SHARED_LIST_RADIO_INPUT = 'input[id="add_to_lists"]'; + +export const SHARED_LIST_CHECKBOX = '.euiTableRow .euiCheckbox__input'; + +export const ADD_TO_RULE_RADIO_LABEL = 'label [data-test-subj="addToRuleRadioOption"]'; + +export const ADD_TO_RULE_OR_LIST_SECTION = '[data-test-subj="exceptionItemAddToRuleOrListSection"]'; + +export const OS_SELECTION_SECTION = '[data-test-subj="osSelectionDropdown"]'; + +export const OS_INPUT = '[data-test-subj="osSelectionDropdown"] [data-test-subj="comboBoxInput"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts index bad0770ffd76..606ee4ae7a04 100644 --- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts @@ -43,6 +43,8 @@ export const FALSE_POSITIVES_DETAILS = 'False positive examples'; export const INDEX_PATTERNS_DETAILS = 'Index patterns'; +export const ENDPOINT_EXCEPTIONS_TAB = 'a[data-test-subj="navigation-endpoint_exceptions"]'; + export const INDICATOR_INDEX_PATTERNS = 'Indicator index patterns'; export const INDICATOR_INDEX_QUERY = 'Indicator index query'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index 9df0fb86f218..8003f1ba3c30 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -16,6 +16,7 @@ import { GROUP_BY_TOP_INPUT, ACKNOWLEDGED_ALERTS_FILTER_BTN, LOADING_ALERTS_PANEL, + MANAGE_ALERT_DETECTION_RULES_BTN, MARK_ALERT_ACKNOWLEDGED_BTN, OPEN_ALERT_BTN, OPENED_ALERTS_FILTER_BTN, @@ -25,6 +26,9 @@ import { TIMELINE_CONTEXT_MENU_BTN, CLOSE_FLYOUT, OPEN_ANALYZER_BTN, + TAKE_ACTION_BTN, + TAKE_ACTION_MENU, + ADD_ENDPOINT_EXCEPTION_BTN, } from '../screens/alerts'; import { REFRESH_BUTTON } from '../screens/security_header'; import { @@ -41,10 +45,44 @@ import { CELL_EXPANSION_POPOVER, USER_DETAILS_LINK, } from '../screens/alerts_details'; +import { FIELD_INPUT } from '../screens/exceptions'; export const addExceptionFromFirstAlert = () => { cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); - cy.get(ADD_EXCEPTION_BTN).click(); + cy.root() + .pipe(($el) => { + $el.find(ADD_EXCEPTION_BTN).trigger('click'); + return $el.find(FIELD_INPUT); + }) + .should('be.visible'); +}; + +export const openAddEndpointExceptionFromFirstAlert = () => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); + cy.root() + .pipe(($el) => { + $el.find(ADD_ENDPOINT_EXCEPTION_BTN).trigger('click'); + return $el.find(FIELD_INPUT); + }) + .should('be.visible'); +}; + +export const openAddExceptionFromAlertDetails = () => { + cy.get(EXPAND_ALERT_BTN).first().click({ force: true }); + + cy.root() + .pipe(($el) => { + $el.find(TAKE_ACTION_BTN).trigger('click'); + return $el.find(TAKE_ACTION_MENU); + }) + .should('be.visible'); + + cy.root() + .pipe(($el) => { + $el.find(ADD_EXCEPTION_BTN).trigger('click'); + return $el.find(ADD_EXCEPTION_BTN); + }) + .should('not.be.visible'); }; export const closeFirstAlert = () => { @@ -106,6 +144,10 @@ export const goToClosedAlerts = () => { cy.get(TIMELINE_COLUMN_SPINNER).should('not.exist'); }; +export const goToManageAlertsDetectionRules = () => { + cy.get(MANAGE_ALERT_DETECTION_RULES_BTN).should('exist').click({ force: true }); +}; + export const goToOpenedAlerts = () => { cy.get(OPENED_ALERTS_FILTER_BTN).click({ force: true }); cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts index 68909d37dd1e..fd070cfcda55 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts @@ -7,6 +7,14 @@ import type { ExceptionList, ExceptionListItem } from '../../objects/exception'; +export const createEndpointExceptionList = () => + cy.request({ + method: 'POST', + url: '/api/endpoint_list', + headers: { 'kbn-xsrf': 'cypress-creds' }, + failOnStatusCode: false, + }); + export const createExceptionList = ( exceptionList: ExceptionList, exceptionListId = 'exception_list_testing' diff --git a/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts b/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts index 5525fdcca8fc..1e89e14c1280 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { Exception } from '../objects/exception'; import { FIELD_INPUT, OPERATOR_INPUT, @@ -14,6 +15,15 @@ import { VALUES_INPUT, VALUES_MATCH_ANY_INPUT, EXCEPTION_EDIT_FLYOUT_SAVE_BTN, + CLOSE_ALERTS_CHECKBOX, + CONFIRM_BTN, + EXCEPTION_ITEM_NAME_INPUT, + CLOSE_SINGLE_ALERT_CHECKBOX, + ADD_TO_RULE_RADIO_LABEL, + ADD_TO_SHARED_LIST_RADIO_LABEL, + SHARED_LIST_CHECKBOX, + OS_SELECTION_SECTION, + OS_INPUT, } from '../screens/exceptions'; export const addExceptionEntryFieldValueOfItemX = ( @@ -56,8 +66,72 @@ export const closeExceptionBuilderFlyout = () => { export const editException = (updatedField: string, itemIndex = 0, fieldIndex = 0) => { addExceptionEntryFieldValueOfItemX(`${updatedField}{downarrow}{enter}`, itemIndex, fieldIndex); addExceptionEntryFieldValueValue('foo', itemIndex); +}; + +export const addExceptionFlyoutItemName = (name: string) => { + cy.root() + .pipe(($el) => { + return $el.find(EXCEPTION_ITEM_NAME_INPUT); + }) + .type(`${name}{enter}`) + .should('have.value', name); +}; + +export const editExceptionFlyoutItemName = (name: string) => { + cy.root() + .pipe(($el) => { + return $el.find(EXCEPTION_ITEM_NAME_INPUT); + }) + .clear() + .type(`${name}{enter}`) + .should('have.value', name); +}; +export const selectBulkCloseAlerts = () => { + cy.get(CLOSE_ALERTS_CHECKBOX).click({ force: true }); +}; + +export const selectCloseSingleAlerts = () => { + cy.get(CLOSE_SINGLE_ALERT_CHECKBOX).click({ force: true }); +}; + +export const addExceptionConditions = (exception: Exception) => { + cy.root() + .pipe(($el) => { + return $el.find(FIELD_INPUT); + }) + .type(`${exception.field}{downArrow}{enter}`); + cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`); + exception.values.forEach((value) => { + cy.get(VALUES_INPUT).type(`${value}{enter}`); + }); +}; + +export const submitNewExceptionItem = () => { + cy.get(CONFIRM_BTN).click(); + cy.get(CONFIRM_BTN).should('not.exist'); +}; + +export const submitEditedExceptionItem = () => { cy.get(EXCEPTION_EDIT_FLYOUT_SAVE_BTN).click(); - cy.get(EXCEPTION_EDIT_FLYOUT_SAVE_BTN).should('have.attr', 'disabled'); cy.get(EXCEPTION_EDIT_FLYOUT_SAVE_BTN).should('not.exist'); }; + +export const selectAddToRuleRadio = () => { + cy.get(ADD_TO_RULE_RADIO_LABEL).click(); +}; + +export const selectSharedListToAddExceptionTo = (numListsToCheck = 1) => { + cy.get(ADD_TO_SHARED_LIST_RADIO_LABEL).click(); + for (let i = 0; i < numListsToCheck; i++) { + cy.get(SHARED_LIST_CHECKBOX) + .eq(i) + .pipe(($el) => $el.trigger('click')) + .should('be.checked'); + } +}; + +export const selectOs = (os: string) => { + cy.get(OS_SELECTION_SECTION).should('exist'); + cy.get(OS_INPUT).type(`${os}{downArrow}{enter}`); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 3f7d31f06047..bbfadc337f5e 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -10,13 +10,8 @@ import { RULE_STATUS } from '../screens/create_new_rule'; import { ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN, ADD_EXCEPTIONS_BTN_FROM_VIEWER_HEADER, - CLOSE_ALERTS_CHECKBOX, - CONFIRM_BTN, EXCEPTION_ITEM_VIEWER_SEARCH, FIELD_INPUT, - LOADING_SPINNER, - OPERATOR_INPUT, - VALUES_INPUT, } from '../screens/exceptions'; import { ALERTS_TAB, @@ -32,8 +27,15 @@ import { DETAILS_DESCRIPTION, EXCEPTION_ITEM_ACTIONS_BUTTON, EDIT_EXCEPTION_BTN, + ENDPOINT_EXCEPTIONS_TAB, EDIT_RULE_SETTINGS_LINK, } from '../screens/rule_details'; +import { + addExceptionConditions, + addExceptionFlyoutItemName, + selectBulkCloseAlerts, + submitNewExceptionItem, +} from './exceptions'; import { addsFields, closeFieldsBrowser, filterFieldsBrowser } from './fields_browser'; export const enablesRule = () => { @@ -46,21 +48,6 @@ export const enablesRule = () => { }); }; -export const addsException = (exception: Exception) => { - cy.get(LOADING_SPINNER).should('exist'); - cy.get(LOADING_SPINNER).should('not.exist'); - cy.get(FIELD_INPUT).should('exist'); - cy.get(FIELD_INPUT).type(`${exception.field}{enter}`); - cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`); - exception.values.forEach((value) => { - cy.get(VALUES_INPUT).type(`${value}{enter}`); - }); - cy.get(CLOSE_ALERTS_CHECKBOX).click({ force: true }); - cy.get(CONFIRM_BTN).click(); - cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); - cy.get(CONFIRM_BTN).should('not.exist'); -}; - export const addsFieldsToTimeline = (search: string, fields: string[]) => { cy.get(FIELDS_BROWSER_BTN).click(); filterFieldsBrowser(search); @@ -86,7 +73,7 @@ export const searchForExceptionItem = (query: string) => { }); }; -const addExceptionFlyoutFromViewerHeader = () => { +export const addExceptionFlyoutFromViewerHeader = () => { cy.root() .pipe(($el) => { $el.find(ADD_EXCEPTIONS_BTN_FROM_VIEWER_HEADER).trigger('click'); @@ -97,28 +84,16 @@ const addExceptionFlyoutFromViewerHeader = () => { export const addExceptionFromRuleDetails = (exception: Exception) => { addExceptionFlyoutFromViewerHeader(); - cy.get(FIELD_INPUT).type(`${exception.field}{downArrow}{enter}`); - cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`); - exception.values.forEach((value) => { - cy.get(VALUES_INPUT).type(`${value}{enter}`); - }); - cy.get(CLOSE_ALERTS_CHECKBOX).click({ force: true }); - cy.get(CONFIRM_BTN).click(); - cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); - cy.get(CONFIRM_BTN).should('not.exist'); + addExceptionConditions(exception); + submitNewExceptionItem(); }; -export const addFirstExceptionFromRuleDetails = (exception: Exception) => { +export const addFirstExceptionFromRuleDetails = (exception: Exception, name: string) => { openExceptionFlyoutFromEmptyViewerPrompt(); - cy.get(FIELD_INPUT).type(`${exception.field}{downArrow}{enter}`); - cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`); - exception.values.forEach((value) => { - cy.get(VALUES_INPUT).type(`${value}{enter}`); - }); - cy.get(CLOSE_ALERTS_CHECKBOX).click({ force: true }); - cy.get(CONFIRM_BTN).click(); - cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); - cy.get(CONFIRM_BTN).should('not.exist'); + addExceptionFlyoutItemName(name); + addExceptionConditions(exception); + selectBulkCloseAlerts(); + submitNewExceptionItem(); }; export const goToAlertsTab = () => { @@ -130,9 +105,13 @@ export const goToExceptionsTab = () => { cy.get(EXCEPTIONS_TAB).click(); }; +export const goToEndpointExceptionsTab = () => { + cy.get(ENDPOINT_EXCEPTIONS_TAB).should('exist'); + cy.get(ENDPOINT_EXCEPTIONS_TAB).click(); +}; + export const openEditException = (index = 0) => { cy.get(EXCEPTION_ITEM_ACTIONS_BUTTON).eq(index).click({ force: true }); - cy.get(EDIT_EXCEPTION_BTN).eq(index).click({ force: true }); }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx index de5eca78aaff..0613f08b7f57 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx @@ -7,21 +7,24 @@ import React from 'react'; import type { ReactWrapper } from 'enzyme'; -import { mount } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { waitFor } from '@testing-library/react'; -import { AddExceptionFlyout } from '.'; -import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; -import { useAsync } from '@kbn/securitysolution-hook-utils'; import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock'; -import { useFetchIndex } from '../../../../common/containers/source'; +import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; +import type { EntriesArray } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; import { createStubIndexPattern, stubIndexPattern } from '@kbn/data-plugin/common/stubs'; -import { useAddOrUpdateException } from '../../logic/use_add_exception'; -import { useFetchOrCreateRuleExceptionList } from '../../logic/use_fetch_or_create_rule_exception_list'; + +import { AddExceptionFlyout } from '.'; +import { useFetchIndex } from '../../../../common/containers/source'; +import { useCreateOrUpdateException } from '../../logic/use_create_update_exception'; +import { useFetchIndexPatterns } from '../../logic/use_exception_flyout_data'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; import * as helpers from '../../utils/helpers'; -import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import type { EntriesArray } from '@kbn/securitysolution-io-ts-list-types'; +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; +import * as i18n from './translations'; import { TestProviders } from '../../../../common/mock'; @@ -29,59 +32,61 @@ import { getRulesEqlSchemaMock, getRulesSchemaMock, } from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; -import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; import type { AlertData } from '../../utils/types'; +import { useFindRules } from '../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules'; +import { useFindExceptionListReferences } from '../../logic/use_find_references'; jest.mock('../../../../detections/containers/detection_engine/alerts/use_signal_index'); jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/containers/source'); jest.mock('../../../../detections/containers/detection_engine/rules'); -jest.mock('../../logic/use_add_exception'); -jest.mock('../../logic/use_fetch_or_create_rule_exception_list'); +jest.mock('../../logic/use_create_update_exception'); +jest.mock('../../logic/use_exception_flyout_data'); +jest.mock('../../logic/use_find_references'); jest.mock('@kbn/securitysolution-hook-utils', () => ({ ...jest.requireActual('@kbn/securitysolution-hook-utils'), useAsync: jest.fn(), })); jest.mock('../../../../detections/containers/detection_engine/rules/use_rule_async'); jest.mock('@kbn/lists-plugin/public'); +jest.mock('../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules'); const mockGetExceptionBuilderComponentLazy = getExceptionBuilderComponentLazy as jest.Mock< ReturnType >; -const mockUseAsync = useAsync as jest.Mock>; -const mockUseAddOrUpdateException = useAddOrUpdateException as jest.Mock< - ReturnType +const mockUseAddOrUpdateException = useCreateOrUpdateException as jest.Mock< + ReturnType >; -const mockUseFetchOrCreateRuleExceptionList = useFetchOrCreateRuleExceptionList as jest.Mock< - ReturnType +const mockFetchIndexPatterns = useFetchIndexPatterns as jest.Mock< + ReturnType >; const mockUseSignalIndex = useSignalIndex as jest.Mock>>; const mockUseFetchIndex = useFetchIndex as jest.Mock; -const mockUseRuleAsync = useRuleAsync as jest.Mock; +const mockUseFindRules = useFindRules as jest.Mock; +const mockUseFindExceptionListReferences = useFindExceptionListReferences as jest.Mock; + +const alertDataMock: AlertData = { + '@timestamp': '1234567890', + _id: 'test-id', + file: { path: 'test/path' }, +}; describe('When the add exception modal is opened', () => { - const ruleName = 'test rule'; let defaultEndpointItems: jest.SpyInstance< ReturnType >; beforeEach(() => { mockGetExceptionBuilderComponentLazy.mockReturnValue( - + ); defaultEndpointItems = jest.spyOn(helpers, 'defaultEndpointExceptionItems'); - mockUseAsync.mockImplementation(() => ({ - start: jest.fn(), - loading: false, - error: {}, - result: true, + mockUseAddOrUpdateException.mockImplementation(() => [false, jest.fn()]); + mockFetchIndexPatterns.mockImplementation(() => ({ + isLoading: false, + indexPatterns: stubIndexPattern, })); - mockUseAddOrUpdateException.mockImplementation(() => [{ isLoading: false }, jest.fn()]); - mockUseFetchOrCreateRuleExceptionList.mockImplementation(() => [ - false, - getExceptionListSchemaMock(), - ]); mockUseSignalIndex.mockImplementation(() => ({ loading: false, signalIndexName: 'mock-siem-signals-index', @@ -92,9 +97,48 @@ describe('When the add exception modal is opened', () => { indexPatterns: stubIndexPattern, }, ]); - mockUseRuleAsync.mockImplementation(() => ({ - rule: getRulesSchemaMock(), + mockUseFindRules.mockImplementation(() => ({ + data: { + rules: [ + { + ...getRulesSchemaMock(), + exceptions_list: [], + } as Rule, + ], + total: 1, + }, + isFetched: true, })); + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + my_list_id: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.DETECTION, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.DETECTION, + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); }); afterEach(() => { @@ -106,87 +150,705 @@ describe('When the add exception modal is opened', () => { let wrapper: ReactWrapper; beforeEach(() => { // Mocks one of the hooks as loading - mockUseFetchIndex.mockImplementation(() => [ - true, - { - indexPatterns: stubIndexPattern, - }, - ]); + mockFetchIndexPatterns.mockImplementation(() => ({ + isLoading: true, + indexPatterns: { fields: [], title: 'foo' }, + })); + wrapper = mount( ); }); + it('should show the loading spinner', () => { expect(wrapper.find('[data-test-subj="loadingAddExceptionFlyout"]').exists()).toBeTruthy(); }); }); - describe('when there is no alert data passed to an endpoint list exception', () => { + describe('exception list type of "endpoint"', () => { + describe('common functionality to test regardless of alert input', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.ADD_ENDPOINT_EXCEPTION + ); + expect(wrapper.find('[data-test-subj="addExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.ADD_ENDPOINT_EXCEPTION + ); + }); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); + }); + + it('should render the exception builder', () => { + expect(wrapper.find('[data-test-subj="alertExceptionBuilder"]').exists()).toBeTruthy(); + }); + + it('does NOT render options to add exception to a rule or shared list', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemAddToRuleOrListSection"]').exists() + ).toBeFalsy(); + }); + + it('should contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeTruthy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).not.toBeTruthy(); + }); + }); + + describe('alert data is passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('should prepopulate endpoint items', () => { + expect(defaultEndpointItems).toHaveBeenCalled(); + }); + + it('should render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeTruthy(); + }); + + it('should have the bulk close alerts checkbox disabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).toBeDisabled(); + }); + + it('should NOT render the os selection dropdown', () => { + expect(wrapper.find('[data-test-subj="osSelectionDropdown"]').exists()).toBeFalsy(); + }); + }); + + describe('bulk closeable alert data is passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + mockUseFetchIndex.mockImplementation(() => [ + false, + { + indexPatterns: createStubIndexPattern({ + spec: { + id: '1234', + title: 'filebeat-*', + fields: { + 'event.code': { + name: 'event.code', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.path.caseless': { + name: 'file.path.caseless', + type: 'string', + aggregatable: true, + searchable: true, + }, + subject_name: { + name: 'subject_name', + type: 'string', + aggregatable: true, + searchable: true, + }, + trusted: { + name: 'trusted', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.hash.sha256': { + name: 'file.hash.sha256', + type: 'string', + aggregatable: true, + searchable: true, + }, + }, + }, + }), + }, + ]); + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ + exceptionItems: [ + { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'file.hash.sha256', operator: 'included', type: 'match' }], + }, + ], + }) + ); + }); + + it('should prepopulate endpoint items', () => { + expect(defaultEndpointItems).toHaveBeenCalled(); + }); + + it('should render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeTruthy(); + }); + + it('should have the bulk close checkbox enabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).not.toBeDisabled(); + }); + + describe('when a "is in list" entry is added', () => { + it('should have the bulk close checkbox disabled', async () => { + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + + await waitFor(() => + callProps.onChange({ + exceptionItems: [ + ...callProps.exceptionListItems, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { field: 'event.code', operator: 'included', type: 'list' }, + ] as EntriesArray, + }, + ], + }) + ); + + expect( + wrapper + .find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + }); + }); + + describe('alert data NOT passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('should NOT render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeFalsy(); + }); + + it('should render the os selection dropdown', () => { + expect(wrapper.find('[data-test-subj="osSelectionDropdown"]').exists()).toBeTruthy(); + }); + }); + }); + + describe('exception list type is NOT "endpoint" ("rule_default" or "detection")', () => { + describe('common features to test regardless of alert input', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + ); + }); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.CREATE_RULE_EXCEPTION + ); + expect(wrapper.find('[data-test-subj="addExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.CREATE_RULE_EXCEPTION + ); + }); + + it('should NOT prepopulate items', () => { + expect(defaultEndpointItems).not.toHaveBeenCalled(); + }); + + // button is disabled until there are exceptions, a name, and selection made on + // add to rule or lists section + it('has the add exception button disabled', () => { + expect( + wrapper.find('button[data-test-subj="addExceptionConfirmButton"]').getDOMNode() + ).toBeDisabled(); + }); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); + }); + + it('should NOT render the os selection dropdown', () => { + expect(wrapper.find('[data-test-subj="osSelectionDropdown"]').exists()).toBeFalsy(); + }); + + it('should render the exception builder', () => { + expect(wrapper.find('[data-test-subj="alertExceptionBuilder"]').exists()).toBeTruthy(); + }); + + it('renders options to add exception to a rule or shared list and has "add to rule" selected by default', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemAddToRuleOrListSection"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="addToRuleOptionsRadio"] input').getDOMNode() + ).toBeChecked(); + }); + + it('should NOT contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeFalsy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).not.toBeTruthy(); + }); + }); + + describe('alert data is passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + ); + }); + + it('should render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('input[data-test-subj="closeAlertOnAddExceptionCheckbox"]').getDOMNode() + ).not.toBeDisabled(); + }); + + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).toBeDisabled(); + }); + }); + + describe('bulk closeable alert data is passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + mockUseFetchIndex.mockImplementation(() => [ + false, + { + indexPatterns: createStubIndexPattern({ + spec: { + id: '1234', + title: 'filebeat-*', + fields: { + 'event.code': { + name: 'event.code', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.path.caseless': { + name: 'file.path.caseless', + type: 'string', + aggregatable: true, + searchable: true, + }, + subject_name: { + name: 'subject_name', + type: 'string', + aggregatable: true, + searchable: true, + }, + trusted: { + name: 'trusted', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.hash.sha256': { + name: 'file.hash.sha256', + type: 'string', + aggregatable: true, + searchable: true, + }, + }, + }, + }), + }, + ]); + wrapper = mount( + + + + ); + + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ + exceptionItems: [ + { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'file.hash.sha256', operator: 'included', type: 'match' }], + }, + ], + }) + ); + }); + + it('should render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('input[data-test-subj="closeAlertOnAddExceptionCheckbox"]').getDOMNode() + ).not.toBeDisabled(); + }); + + it('should have the bulk close checkbox enabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).not.toBeDisabled(); + }); + + describe('when a "is in list" entry is added', () => { + it('should have the bulk close checkbox disabled', async () => { + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + + await waitFor(() => + callProps.onChange({ + exceptionItems: [ + ...callProps.exceptionListItems, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { field: 'event.code', operator: 'included', type: 'list' }, + ] as EntriesArray, + }, + ], + }) + ); + + expect( + wrapper + .find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + }); + }); + + describe('alert data NOT passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('should NOT render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeFalsy(); + }); + + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).toBeDisabled(); + }); + }); + }); + + /* Say for example, from the lists management or lists details page */ + describe('when no rules are passed in', () => { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => callProps.onChange({ exceptionItems: [] })); - }); - it('has the add exception button disabled', () => { - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).toBeDisabled(); + await waitFor(() => + callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + ); }); - it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + + it('allows large value lists', () => { + expect(wrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeTruthy(); }); - it('should not render the close on add exception checkbox', () => { + + it('defaults to selecting add to rule option, displaying rules selection table', () => { + expect(wrapper.find('[data-test-subj="addExceptionToRulesTable"]').exists()).toBeTruthy(); expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() - ).toBeFalsy(); - }); - it('should contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); - }); - it('should render the os selection dropdown', () => { - expect(wrapper.find('[data-test-subj="os-selection-dropdown"]').exists()).toBeTruthy(); + wrapper.find('[data-test-subj="selectRulesToAddToOptionRadio"] input').getDOMNode() + ).toHaveAttribute('checked'); }); }); - describe('when there is alert data passed to an endpoint list exception', () => { + /* Say for example, from the rule details page, exceptions tab, or from an alert */ + describe('when a single rule is passed in', () => { let wrapper: ReactWrapper; beforeEach(async () => { - const alertDataMock: AlertData = { - '@timestamp': '1234567890', - _id: 'test-id', - file: { path: 'test/path' }, - }; wrapper = mount( ); @@ -195,119 +857,304 @@ describe('When the add exception modal is opened', () => { callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) ); }); - it('has the add exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); + it('does not allow large value list selection for query rule', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeTruthy(); }); - it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + + it('does not allow large value list selection if EQL rule', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeFalsy(); }); - it('should prepopulate endpoint items', () => { - expect(defaultEndpointItems).toHaveBeenCalled(); + + it('does not allow large value list selection if threshold rule', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeFalsy(); }); - it('should render the close on add exception checkbox', () => { + + it('does not allow large value list selection if new trems rule', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeFalsy(); + }); + + it('defaults to selecting add to rule radio option', () => { expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + wrapper.find('[data-test-subj="exceptionItemAddToRuleOrListSection"]').exists() ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="addToRuleOptionsRadio"] input').getDOMNode() + ).toBeChecked(); }); - it('should have the bulk close checkbox disabled', () => { + + it('disables add to shared lists option if rule has no shared exception lists attached already', () => { expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() + wrapper.find('[data-test-subj="addToListsRadioOption"] input').getDOMNode() ).toBeDisabled(); }); - it('should contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); - }); - it('should not display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy(); - }); - it('should not render the os selection dropdown', () => { - expect(wrapper.find('[data-test-subj="os-selection-dropdown"]').exists()).toBeFalsy(); + + it('enables add to shared lists option if rule has shared list', () => { + wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="addToListsRadioOption"] input').getDOMNode() + ).toBeEnabled(); }); }); - describe('when there is alert data passed to a detection list exception', () => { + /* Say for example, add exception item from rules bulk action */ + describe('when multiple rules are passed in - bulk action', () => { let wrapper: ReactWrapper; beforeEach(async () => { - const alertDataMock: AlertData = { - '@timestamp': '1234567890', - _id: 'test-id', - file: { path: 'test/path' }, - }; wrapper = mount( ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; await waitFor(() => - callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) ); }); - it('has the add exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); - }); - it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); - }); - it('should not prepopulate endpoint items', () => { - expect(defaultEndpointItems).not.toHaveBeenCalled(); + + it('allows large value lists', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeTruthy(); }); - it('should render the close on add exception checkbox', () => { + + it('defaults to selecting add to rules radio option', () => { expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + wrapper.find('[data-test-subj="exceptionItemAddToRuleOrListSection"]').exists() ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="addToRulesOptionsRadio"] input').getDOMNode() + ).toBeChecked(); }); - it('should have the bulk close checkbox disabled', () => { + + it('disables add to shared lists option if rules have no shared lists in common', () => { expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() + wrapper.find('[data-test-subj="addToListsRadioOption"] input').getDOMNode() ).toBeDisabled(); }); - it('should not display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy(); + + it('enables add to shared lists option if rules have at least one shared list in common', () => { + wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="addToListsRadioOption"] input').getDOMNode() + ).toBeEnabled(); }); }); describe('when there is an exception being created on a sequence eql rule type', () => { let wrapper: ReactWrapper; beforeEach(async () => { - mockUseRuleAsync.mockImplementation(() => ({ - rule: { - ...getRulesEqlSchemaMock(), - query: - 'sequence [process where process.name = "test.exe"] [process where process.name = "explorer.exe"]', - }, - })); - const alertDataMock: AlertData = { - '@timestamp': '1234567890', - _id: 'test-id', - file: { path: 'test/path' }, - }; wrapper = mount( ); @@ -316,177 +1163,58 @@ describe('When the add exception modal is opened', () => { callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) ); }); - it('has the add exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); - }); + it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="alertExceptionBuilder"]').exists()).toBeTruthy(); }); + it('should not prepopulate endpoint items', () => { expect(defaultEndpointItems).not.toHaveBeenCalled(); }); - it('should render the close on add exception checkbox', () => { + + it('should render the close single alert checkbox', () => { expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() ).toBeTruthy(); }); + it('should have the bulk close checkbox disabled', () => { expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() ).toBeDisabled(); }); + it('should display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeTruthy(); }); }); - describe('when there is bulk-closeable alert data passed to an endpoint list exception', () => { - let wrapper: ReactWrapper; - - beforeEach(async () => { - mockUseFetchIndex.mockImplementation(() => [ - false, - { - indexPatterns: createStubIndexPattern({ - spec: { - id: '1234', - title: 'filebeat-*', - fields: { - 'event.code': { - name: 'event.code', - type: 'string', - aggregatable: true, - searchable: true, - }, - 'file.path.caseless': { - name: 'file.path.caseless', - type: 'string', - aggregatable: true, - searchable: true, - }, - subject_name: { - name: 'subject_name', - type: 'string', - aggregatable: true, - searchable: true, - }, - trusted: { - name: 'trusted', - type: 'string', - aggregatable: true, - searchable: true, - }, - 'file.hash.sha256': { - name: 'file.hash.sha256', - type: 'string', - aggregatable: true, - searchable: true, - }, - }, - }, - }), - }, - ]); - - const alertDataMock: AlertData = { - '@timestamp': '1234567890', - _id: 'test-id', - file: { path: 'test/path' }, - }; - wrapper = mount( + describe('error states', () => { + test('when there are exception builder errors submit button is disabled', async () => { + const wrapper = mount( ); - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => { - return callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); - }); - it('has the add exception button enabled', async () => { + await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); - }); - it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); - }); - it('should prepopulate endpoint items', () => { - expect(defaultEndpointItems).toHaveBeenCalled(); - }); - it('should render the close on add exception checkbox', () => { - expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() - ).toBeTruthy(); - }); - it('should contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); - }); - it('should have the bulk close checkbox enabled', () => { - expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() - ).not.toBeDisabled(); - }); - describe('when a "is in list" entry is added', () => { - it('should have the bulk close checkbox disabled', async () => { - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - - await waitFor(() => - callProps.onChange({ - exceptionItems: [ - ...callProps.exceptionListItems, - { - ...getExceptionListItemSchemaMock(), - entries: [ - { field: 'event.code', operator: 'included', type: 'list' }, - ] as EntriesArray, - }, - ], - }) - ); - - expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() - ).toBeDisabled(); - }); + wrapper.find('button[data-test-subj="addExceptionConfirmButton"]').getDOMNode() + ).toBeDisabled(); }); }); - - test('when there are exception builder errors submit button is disabled', async () => { - const wrapper = mount( - - - - ); - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).toBeDisabled(); - }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx index c7b7050f23ff..6d190913e358 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx @@ -5,13 +5,10 @@ * 2.0. */ -// Component being re-implemented in 8.5 - -/* eslint complexity: ["error", 35]*/ - -import React, { memo, useEffect, useState, useCallback, useMemo } from 'react'; +import React, { memo, useEffect, useCallback, useMemo, useReducer } from 'react'; import styled, { css } from 'styled-components'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; + import { EuiFlyout, EuiFlyoutHeader, @@ -21,62 +18,52 @@ import { EuiButton, EuiButtonEmpty, EuiHorizontalRule, - EuiCheckbox, EuiSpacer, - EuiFormRow, - EuiText, - EuiCallOut, - EuiComboBox, EuiFlexGroup, + EuiLoadingContent, + EuiCallOut, + EuiText, } from '@elastic/eui'; -import type { - CreateExceptionListItemSchema, - ExceptionListItemSchema, - ExceptionListType, - OsTypeArray, -} from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import type { OsTypeArray, ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; import type { ExceptionsBuilderExceptionItem, ExceptionsBuilderReturnExceptionItem, } from '@kbn/securitysolution-list-utils'; -import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; -import type { DataViewBase } from '@kbn/es-query'; -import { useRuleIndices } from '../../../../detections/containers/detection_engine/rules/use_rule_indices'; -import { hasEqlSequenceQuery, isEqlRule } from '../../../../../common/detection_engine/utils'; +import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; + import type { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; -import * as i18nCommon from '../../../../common/translations'; import * as i18n from './translations'; -import * as sharedI18n from '../../utils/translations'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { useKibana } from '../../../../common/lib/kibana'; -import { Loader } from '../../../../common/components/loader'; -import { useAddOrUpdateException } from '../../logic/use_add_exception'; -import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; -import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; -import { useFetchOrCreateRuleExceptionList } from '../../logic/use_fetch_or_create_rule_exception_list'; -import { ExceptionItemComments } from '../item_comments'; import { - enrichNewExceptionItemsWithComments, - enrichExceptionItemsWithOS, - lowercaseHashValues, defaultEndpointExceptionItems, - entryHasListType, - entryHasNonEcsType, retrieveAlertOsTypes, filterIndexPatterns, } from '../../utils/helpers'; -import type { ErrorInfo } from '../error_callout'; -import { ErrorCallout } from '../error_callout'; import type { AlertData } from '../../utils/types'; -import { useFetchIndex } from '../../../../common/containers/source'; +import { initialState, createExceptionItemsReducer } from './reducer'; +import { ExceptionsFlyoutMeta } from '../flyout_components/item_meta_form'; +import { ExceptionsConditions } from '../flyout_components/item_conditions'; +import { useFetchIndexPatterns } from '../../logic/use_exception_flyout_data'; +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; +import { ExceptionItemsFlyoutAlertsActions } from '../flyout_components/alerts_actions'; +import { ExceptionsAddToRulesOrLists } from '../flyout_components/add_exception_to_rule_or_list'; +import { useAddNewExceptionItems } from './use_add_new_exceptions'; +import { entrichNewExceptionItems } from '../flyout_components/utils'; +import { useCloseAlertsFromExceptions } from '../../logic/use_close_alerts'; import { ruleTypesThatAllowLargeValueLists } from '../../utils/constants'; +import { ExceptionItemComments } from '../item_comments'; + +const SectionHeader = styled(EuiTitle)` + ${() => css` + font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; + `} +`; export interface AddExceptionFlyoutProps { - ruleName: string; - ruleId: string; - exceptionListType: ExceptionListType; - ruleIndices: string[]; - dataViewId?: string; + rules: Rule[] | null; + isBulkAction: boolean; + showAlertCloseOptions: boolean; + isEndpointItem: boolean; alertData?: AlertData; /** * The components that use this may or may not define `alertData` @@ -86,40 +73,22 @@ export interface AddExceptionFlyoutProps { */ isAlertDataLoading?: boolean; alertStatus?: Status; - onCancel: () => void; - onConfirm: (didCloseAlert: boolean, didBulkCloseAlert: boolean) => void; - onRuleChange?: () => void; + onCancel: (didRuleChange: boolean) => void; + onConfirm: (didRuleChange: boolean, didCloseAlert: boolean, didBulkCloseAlert: boolean) => void; } -const FlyoutHeader = styled(EuiFlyoutHeader)` - ${({ theme }) => css` - border-bottom: 1px solid ${theme.eui.euiColorLightShade}; - `} -`; - -const FlyoutSubtitle = styled.div` - ${({ theme }) => css` - color: ${theme.eui.euiColorMediumShade}; - `} -`; - -const FlyoutBodySection = styled.section` - ${({ theme }) => css` - padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; - +const FlyoutBodySection = styled(EuiFlyoutBody)` + ${() => css` &.builder-section { overflow-y: scroll; } `} `; -const FlyoutCheckboxesSection = styled(EuiFlyoutBody)` - overflow-y: inherit; - height: auto; - - .euiFlyoutBody__overflowContent { - padding-top: 0; - } +const FlyoutHeader = styled(EuiFlyoutHeader)` + ${({ theme }) => css` + border-bottom: 1px solid ${theme.eui.euiColorLightShade}; + `} `; const FlyoutFooterGroup = styled(EuiFlexGroup)` @@ -129,487 +98,430 @@ const FlyoutFooterGroup = styled(EuiFlexGroup)` `; export const AddExceptionFlyout = memo(function AddExceptionFlyout({ - ruleName, - ruleId, - ruleIndices, - dataViewId, - exceptionListType, + rules, + isBulkAction, + isEndpointItem, alertData, + showAlertCloseOptions, isAlertDataLoading, + alertStatus, onCancel, onConfirm, - onRuleChange, - alertStatus, }: AddExceptionFlyoutProps) { - const { http, unifiedSearch, data } = useKibana().services; - const [errorsExist, setErrorExists] = useState(false); - const [comment, setComment] = useState(''); - const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId); - const [shouldCloseAlert, setShouldCloseAlert] = useState(false); - const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); - const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); - const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState< - ExceptionsBuilderReturnExceptionItem[] - >([]); - const [fetchOrCreateListError, setFetchOrCreateListError] = useState(null); - const { addError, addSuccess, addWarning } = useAppToasts(); - const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); - const memoSignalIndexName = useMemo( - () => (signalIndexName !== null ? [signalIndexName] : []), - [signalIndexName] - ); - const [isSignalIndexPatternLoading, { indexPatterns: signalIndexPatterns }] = - useFetchIndex(memoSignalIndexName); + const { isLoading, indexPatterns } = useFetchIndexPatterns(rules); + const [isSubmitting, submitNewExceptionItems] = useAddNewExceptionItems(); + const [isClosingAlerts, closeAlerts] = useCloseAlertsFromExceptions(); + + const allowLargeValueLists = useMemo((): boolean => { + if (rules != null && rules.length === 1) { + // We'll only block this when we know what rule we're dealing with. + // When dealing with numerous rules that can be a mix of those that do and + // don't work with large value lists we'll need to communicate that to the + // user but not block. + return ruleTypesThatAllowLargeValueLists.includes(rules[0].type); + } else { + return true; + } + }, [rules]); - const { mlJobLoading, ruleIndices: memoRuleIndices } = useRuleIndices( - maybeRule?.machine_learning_job_id, - ruleIndices - ); - const hasDataViewId = dataViewId || maybeRule?.data_view_id || null; - const [dataViewIndexPatterns, setDataViewIndexPatterns] = useState(null); - - useEffect(() => { - const fetchSingleDataView = async () => { - if (hasDataViewId) { - const dv = await data.dataViews.get(hasDataViewId); - setDataViewIndexPatterns(dv); - } - }; + const [ + { + exceptionItemMeta: { name: exceptionItemName }, + listType, + selectedOs, + initialItems, + exceptionItems, + disableBulkClose, + bulkCloseAlerts, + closeSingleAlert, + bulkCloseIndex, + addExceptionToRadioSelection, + selectedRulesToAddTo, + exceptionListsToAddTo, + newComment, + itemConditionValidationErrorExists, + errorSubmitting, + }, + dispatch, + ] = useReducer(createExceptionItemsReducer(), { + ...initialState, + addExceptionToRadioSelection: isBulkAction + ? 'add_to_rules' + : rules != null && rules.length === 1 + ? 'add_to_rule' + : 'select_rules_to_add_to', + listType: isEndpointItem ? ExceptionListTypeEnum.ENDPOINT : ExceptionListTypeEnum.RULE_DEFAULT, + selectedRulesToAddTo: rules != null ? rules : [], + }); - fetchSingleDataView(); - }, [hasDataViewId, data.dataViews, setDataViewIndexPatterns]); + const hasAlertData = useMemo((): boolean => { + return alertData != null; + }, [alertData]); - const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] = useFetchIndex( - hasDataViewId ? [] : memoRuleIndices + /** + * Reducer action dispatchers + * */ + const setInitialExceptionItems = useCallback( + (items: ExceptionsBuilderExceptionItem[]): void => { + dispatch({ + type: 'setInitialExceptionItems', + items, + }); + }, + [dispatch] ); - const indexPattern = useMemo( - (): DataViewBase | null => (hasDataViewId ? dataViewIndexPatterns : indexIndexPatterns), - [hasDataViewId, dataViewIndexPatterns, indexIndexPatterns] + const setExceptionItemsToAdd = useCallback( + (items: ExceptionsBuilderReturnExceptionItem[]): void => { + dispatch({ + type: 'setExceptionItems', + items, + }); + }, + [dispatch] ); - const handleBuilderOnChange = useCallback( - ({ - exceptionItems, - errorExists, - }: { - exceptionItems: ExceptionsBuilderReturnExceptionItem[]; - errorExists: boolean; - }) => { - setExceptionItemsToAdd(exceptionItems); - setErrorExists(errorExists); + const setRadioOption = useCallback( + (option: string): void => { + dispatch({ + type: 'setListOrRuleRadioOption', + option, + }); }, - [setExceptionItemsToAdd] + [dispatch] ); - const handleRuleChange = useCallback( - (ruleChanged: boolean): void => { - if (ruleChanged && onRuleChange) { - onRuleChange(); - } + const setSelectedRules = useCallback( + (rulesSelectedToAdd: Rule[]): void => { + dispatch({ + type: 'setSelectedRulesToAddTo', + rules: rulesSelectedToAdd, + }); }, - [onRuleChange] + [dispatch] ); - const handleDissasociationSuccess = useCallback( - (id: string): void => { - handleRuleChange(true); - addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); - onCancel(); + const setListsToAddExceptionTo = useCallback( + (lists: ExceptionListSchema[]): void => { + dispatch({ + type: 'setAddExceptionToLists', + listsToAddTo: lists, + }); }, - [handleRuleChange, addSuccess, onCancel] + [dispatch] ); - const handleDissasociationError = useCallback( - (error: Error): void => { - addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); - onCancel(); + const setExceptionItemMeta = useCallback( + (value: [string, string]): void => { + dispatch({ + type: 'setExceptionItemMeta', + value, + }); }, - [addError, onCancel] + [dispatch] ); - const onError = useCallback( - (error: Error): void => { - addError(error, { title: i18n.ADD_EXCEPTION_ERROR }); - onCancel(); + const setConditionsValidationError = useCallback( + (errorExists: boolean): void => { + dispatch({ + type: 'setConditionValidationErrorExists', + errorExists, + }); }, - [addError, onCancel] + [dispatch] ); - const onSuccess = useCallback( - (updated: number, conflicts: number): void => { - handleRuleChange(true); - addSuccess(i18n.ADD_EXCEPTION_SUCCESS); - onConfirm(shouldCloseAlert, shouldBulkCloseAlert); - if (conflicts > 0) { - addWarning({ - title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), - text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), - }); - } + const setSelectedOs = useCallback( + (os: OsTypeArray | undefined): void => { + dispatch({ + type: 'setSelectedOsOptions', + selectedOs: os, + }); }, - [addSuccess, addWarning, onConfirm, shouldBulkCloseAlert, shouldCloseAlert, handleRuleChange] + [dispatch] ); - const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( - { - http, - onSuccess, - onError, - } + const setComment = useCallback( + (comment: string): void => { + dispatch({ + type: 'setComment', + comment, + }); + }, + [dispatch] ); - const handleFetchOrCreateExceptionListError = useCallback( - (error: Error, statusCode: number | null, message: string | null): void => { - setFetchOrCreateListError({ - reason: error.message, - code: statusCode, - details: message, - listListId: null, + const setBulkCloseIndex = useCallback( + (index: string[] | undefined): void => { + dispatch({ + type: 'setBulkCloseIndex', + bulkCloseIndex: index, }); }, - [setFetchOrCreateListError] + [dispatch] ); - const [isLoadingExceptionList, ruleExceptionList] = useFetchOrCreateRuleExceptionList({ - http, - ruleId, - exceptionListType, - onError: handleFetchOrCreateExceptionListError, - onSuccess: handleRuleChange, - }); - - const initialExceptionItems = useMemo((): ExceptionsBuilderExceptionItem[] => { - if (exceptionListType === 'endpoint' && alertData != null && ruleExceptionList) { - return defaultEndpointExceptionItems(ruleExceptionList.list_id, ruleName, alertData); - } else { - return []; - } - }, [exceptionListType, ruleExceptionList, ruleName, alertData]); - - useEffect((): void => { - if (isSignalIndexPatternLoading === false && isSignalIndexLoading === false) { - setShouldDisableBulkClose( - entryHasListType(exceptionItemsToAdd) || - entryHasNonEcsType(exceptionItemsToAdd, signalIndexPatterns) || - exceptionItemsToAdd.every((item) => item.entries.length === 0) - ); - } - }, [ - setShouldDisableBulkClose, - exceptionItemsToAdd, - isSignalIndexPatternLoading, - isSignalIndexLoading, - signalIndexPatterns, - ]); - - useEffect((): void => { - if (shouldDisableBulkClose === true) { - setShouldBulkCloseAlert(false); - } - }, [shouldDisableBulkClose]); - - const onCommentChange = useCallback( - (value: string): void => { - setComment(value); + const setCloseSingleAlert = useCallback( + (close: boolean): void => { + dispatch({ + type: 'setCloseSingleAlert', + close, + }); }, - [setComment] + [dispatch] ); - const onCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent): void => { - setShouldCloseAlert(event.currentTarget.checked); + const setBulkCloseAlerts = useCallback( + (bulkClose: boolean): void => { + dispatch({ + type: 'setBulkCloseAlerts', + bulkClose, + }); }, - [setShouldCloseAlert] + [dispatch] ); - const onBulkCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent): void => { - setShouldBulkCloseAlert(event.currentTarget.checked); + const setDisableBulkCloseAlerts = useCallback( + (disableBulkCloseAlerts: boolean): void => { + dispatch({ + type: 'setDisableBulkCloseAlerts', + disableBulkCloseAlerts, + }); }, - [setShouldBulkCloseAlert] + [dispatch] ); - const hasAlertData = useMemo((): boolean => { - return alertData !== undefined; - }, [alertData]); + const setErrorSubmitting = useCallback( + (err: Error | null): void => { + dispatch({ + type: 'setErrorSubmitting', + err, + }); + }, + [dispatch] + ); - const [selectedOs, setSelectedOs] = useState(); + useEffect((): void => { + if (listType === ExceptionListTypeEnum.ENDPOINT && alertData != null) { + setInitialExceptionItems( + defaultEndpointExceptionItems(ENDPOINT_LIST_ID, exceptionItemName, alertData) + ); + } + }, [listType, exceptionItemName, alertData, setInitialExceptionItems]); const osTypesSelection = useMemo((): OsTypeArray => { return hasAlertData ? retrieveAlertOsTypes(alertData) : selectedOs ? [...selectedOs] : []; }, [hasAlertData, alertData, selectedOs]); - const enrichExceptionItems = useCallback((): ExceptionsBuilderReturnExceptionItem[] => { - let enriched: ExceptionsBuilderReturnExceptionItem[] = []; - enriched = - comment !== '' - ? enrichNewExceptionItemsWithComments(exceptionItemsToAdd, [{ comment }]) - : exceptionItemsToAdd; - if (exceptionListType === 'endpoint') { - const osTypes = osTypesSelection; - enriched = lowercaseHashValues(enrichExceptionItemsWithOS(enriched, osTypes)); - } - return enriched; - }, [comment, exceptionItemsToAdd, exceptionListType, osTypesSelection]); - - const onAddExceptionConfirm = useCallback((): void => { - if (addOrUpdateExceptionItems != null) { - const alertIdToClose = shouldCloseAlert && alertData ? alertData._id : undefined; - const bulkCloseIndex = - shouldBulkCloseAlert && signalIndexName != null ? [signalIndexName] : undefined; - addOrUpdateExceptionItems( - maybeRule?.rule_id ?? '', - // This is being rewritten in https://github.com/elastic/kibana/pull/140643 - // As of now, flyout cannot yet create item of type CreateRuleExceptionListItemSchema - enrichExceptionItems() as Array, - alertIdToClose, - bulkCloseIndex - ); + const handleOnSubmit = useCallback(async (): Promise => { + if (submitNewExceptionItems == null) return; + + try { + const ruleDefaultOptions = ['add_to_rule', 'add_to_rules', 'select_rules_to_add_to']; + const addToRules = ruleDefaultOptions.includes(addExceptionToRadioSelection); + const addToSharedLists = addExceptionToRadioSelection === 'add_to_lists'; + + const items = entrichNewExceptionItems({ + itemName: exceptionItemName, + commentToAdd: newComment, + addToRules, + addToSharedLists, + sharedLists: exceptionListsToAddTo, + listType, + selectedOs: osTypesSelection, + items: exceptionItems, + }); + + const addedItems = await submitNewExceptionItems({ + itemsToAdd: items, + selectedRulesToAddTo, + listType, + addToRules: addToRules && !isEmpty(selectedRulesToAddTo), + addToSharedLists: addToSharedLists && !isEmpty(exceptionListsToAddTo), + sharedLists: exceptionListsToAddTo, + }); + + const alertIdToClose = closeSingleAlert && alertData ? alertData._id : undefined; + const ruleStaticIds = addToRules + ? selectedRulesToAddTo.map(({ rule_id: ruleId }) => ruleId) + : (rules ?? []).map(({ rule_id: ruleId }) => ruleId); + + if (closeAlerts != null && !isEmpty(ruleStaticIds) && (bulkCloseAlerts || closeSingleAlert)) { + await closeAlerts(ruleStaticIds, addedItems, alertIdToClose, bulkCloseIndex); + } + + // Rule only would have been updated if we had to create a rule default list + // to attach to it, all shared lists would already be referenced on the rule + onConfirm(true, closeSingleAlert, bulkCloseAlerts); + } catch (e) { + setErrorSubmitting(e); } }, [ - addOrUpdateExceptionItems, - maybeRule, - enrichExceptionItems, - shouldCloseAlert, - shouldBulkCloseAlert, + submitNewExceptionItems, + addExceptionToRadioSelection, + exceptionItemName, + newComment, + exceptionListsToAddTo, + listType, + osTypesSelection, + exceptionItems, + selectedRulesToAddTo, + closeSingleAlert, alertData, - signalIndexName, + rules, + closeAlerts, + bulkCloseAlerts, + onConfirm, + bulkCloseIndex, + setErrorSubmitting, ]); const isSubmitButtonDisabled = useMemo( (): boolean => - fetchOrCreateListError != null || - exceptionItemsToAdd.every((item) => item.entries.length === 0) || - errorsExist, - [fetchOrCreateListError, exceptionItemsToAdd, errorsExist] - ); - - const addExceptionMessage = - exceptionListType === 'endpoint' ? i18n.ADD_ENDPOINT_EXCEPTION : i18n.ADD_EXCEPTION; - - const isRuleEQLSequenceStatement = useMemo((): boolean => { - if (maybeRule != null) { - return isEqlRule(maybeRule.type) && hasEqlSequenceQuery(maybeRule.query); - } - return false; - }, [maybeRule]); - - const OsOptions: Array> = useMemo((): Array< - EuiComboBoxOptionOption - > => { - return [ - { - label: sharedI18n.OPERATING_SYSTEM_WINDOWS, - value: ['windows'], - }, - { - label: sharedI18n.OPERATING_SYSTEM_MAC, - value: ['macos'], - }, - { - label: sharedI18n.OPERATING_SYSTEM_LINUX, - value: ['linux'], - }, - { - label: sharedI18n.OPERATING_SYSTEM_WINDOWS_AND_MAC, - value: ['windows', 'macos'], - }, - ]; - }, []); - - const handleOSSelectionChange = useCallback( - (selectedOptions): void => { - setSelectedOs(selectedOptions[0].value); - }, - [setSelectedOs] + isSubmitting || + isClosingAlerts || + errorSubmitting != null || + exceptionItemName.trim() === '' || + exceptionItems.every((item) => item.entries.length === 0) || + itemConditionValidationErrorExists || + (addExceptionToRadioSelection === 'add_to_lists' && isEmpty(exceptionListsToAddTo)), + [ + isSubmitting, + isClosingAlerts, + errorSubmitting, + exceptionItemName, + exceptionItems, + itemConditionValidationErrorExists, + addExceptionToRadioSelection, + exceptionListsToAddTo, + ] ); - const selectedOStoOptions = useMemo((): Array> => { - return OsOptions.filter((option) => { - return selectedOs === option.value; - }); - }, [selectedOs, OsOptions]); - - const singleSelectionOptions = useMemo(() => { - return { asPlainText: true }; - }, []); + const handleDismissError = useCallback((): void => { + setErrorSubmitting(null); + }, [setErrorSubmitting]); - const hasOsSelection = useMemo(() => { - return exceptionListType === 'endpoint' && !hasAlertData; - }, [exceptionListType, hasAlertData]); + const handleCloseFlyout = useCallback((): void => { + onCancel(false); + }, [onCancel]); - const isExceptionBuilderFormDisabled = useMemo(() => { - return hasOsSelection && selectedOs === undefined; - }, [hasOsSelection, selectedOs]); - - const allowLargeValueLists = useMemo( - () => (maybeRule != null ? ruleTypesThatAllowLargeValueLists.includes(maybeRule.type) : false), - [maybeRule] - ); + const addExceptionMessage = useMemo(() => { + return listType === ExceptionListTypeEnum.ENDPOINT + ? i18n.ADD_ENDPOINT_EXCEPTION + : i18n.CREATE_RULE_EXCEPTION; + }, [listType]); return ( -

{addExceptionMessage}

+

{addExceptionMessage}

- - - {ruleName} -
- {fetchOrCreateListError != null && ( - - } + {!isLoading && ( + + {errorSubmitting != null && ( + <> + + {i18n.SUBMIT_ERROR_DISMISS_MESSAGE} + + + {i18n.SUBMIT_ERROR_DISMISS_BUTTON} + + + + + )} + - - )} - {fetchOrCreateListError == null && - (isLoadingExceptionList || - isIndexPatternLoading || - isSignalIndexLoading || - isAlertDataLoading || - isSignalIndexPatternLoading) && ( - - )} - {fetchOrCreateListError == null && - indexPattern != null && - !isSignalIndexLoading && - !isSignalIndexPatternLoading && - !isLoadingExceptionList && - !isIndexPatternLoading && - !isRuleLoading && - !mlJobLoading && - !isAlertDataLoading && - ruleExceptionList && ( - <> - - {isRuleEQLSequenceStatement && ( - <> - - - - )} - {i18n.EXCEPTION_BUILDER_INFO} - - {exceptionListType === 'endpoint' && !hasAlertData && ( - <> - - - - - - )} - {getExceptionBuilderComponentLazy({ - allowLargeValueLists, - httpService: http, - autocompleteService: unifiedSearch.autocomplete, - exceptionListItems: initialExceptionItems, - listType: exceptionListType, - osTypes: osTypesSelection, - listId: ruleExceptionList.list_id, - listNamespaceType: ruleExceptionList.namespace_type, - listTypeSpecificIndexPatternFilter: filterIndexPatterns, - ruleName, - indexPatterns: indexPattern, - isOrDisabled: isExceptionBuilderFormDisabled, - isAndDisabled: isExceptionBuilderFormDisabled, - isNestedDisabled: isExceptionBuilderFormDisabled, - dataTestSubj: 'alert-exception-builder', - idAria: 'alert-exception-builder', - onChange: handleBuilderOnChange, - isDisabled: isExceptionBuilderFormDisabled, - })} - - - - + + + {listType !== ExceptionListTypeEnum.ENDPOINT && ( + <> + + + + )} + + +

{i18n.COMMENTS_SECTION_TITLE(0)}

+ + } + newCommentValue={newComment} + newCommentOnChange={setComment} + /> + {showAlertCloseOptions && ( + <> + + -
- - - {alertData != null && alertStatus !== 'closed' && ( - - - - )} - - - - {exceptionListType === 'endpoint' && ( - <> - - - {i18n.ENDPOINT_QUARANTINE_TEXT} - - - )} - - - )} - {fetchOrCreateListError == null && ( - - - - {i18n.CANCEL} - - - - {addExceptionMessage} - - - + + )} + )} + + + + {i18n.CANCEL} + + + + {addExceptionMessage} + + +
); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts new file mode 100644 index 000000000000..3e5f8afd1962 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts @@ -0,0 +1,250 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ExceptionListSchema, OsTypeArray } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import type { + ExceptionsBuilderExceptionItem, + ExceptionsBuilderReturnExceptionItem, +} from '@kbn/securitysolution-list-utils'; + +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; + +export interface State { + exceptionItemMeta: { name: string }; + listType: ExceptionListTypeEnum; + initialItems: ExceptionsBuilderExceptionItem[]; + exceptionItems: ExceptionsBuilderReturnExceptionItem[]; + newComment: string; + addExceptionToRadioSelection: string; + itemConditionValidationErrorExists: boolean; + closeSingleAlert: boolean; + bulkCloseAlerts: boolean; + disableBulkClose: boolean; + bulkCloseIndex: string[] | undefined; + selectedOs: OsTypeArray | undefined; + exceptionListsToAddTo: ExceptionListSchema[]; + selectedRulesToAddTo: Rule[]; + errorSubmitting: Error | null; +} + +export const initialState: State = { + initialItems: [], + exceptionItems: [], + exceptionItemMeta: { name: '' }, + newComment: '', + itemConditionValidationErrorExists: false, + closeSingleAlert: false, + bulkCloseAlerts: false, + disableBulkClose: false, + bulkCloseIndex: undefined, + selectedOs: undefined, + exceptionListsToAddTo: [], + addExceptionToRadioSelection: 'add_to_rule', + selectedRulesToAddTo: [], + listType: ExceptionListTypeEnum.RULE_DEFAULT, + errorSubmitting: null, +}; + +export type Action = + | { + type: 'setExceptionItemMeta'; + value: [string, string]; + } + | { + type: 'setInitialExceptionItems'; + items: ExceptionsBuilderExceptionItem[]; + } + | { + type: 'setExceptionItems'; + items: ExceptionsBuilderReturnExceptionItem[]; + } + | { + type: 'setConditionValidationErrorExists'; + errorExists: boolean; + } + | { + type: 'setComment'; + comment: string; + } + | { + type: 'setCloseSingleAlert'; + close: boolean; + } + | { + type: 'setBulkCloseAlerts'; + bulkClose: boolean; + } + | { + type: 'setDisableBulkCloseAlerts'; + disableBulkCloseAlerts: boolean; + } + | { + type: 'setBulkCloseIndex'; + bulkCloseIndex: string[] | undefined; + } + | { + type: 'setSelectedOsOptions'; + selectedOs: OsTypeArray | undefined; + } + | { + type: 'setAddExceptionToLists'; + listsToAddTo: ExceptionListSchema[]; + } + | { + type: 'setListOrRuleRadioOption'; + option: string; + } + | { + type: 'setSelectedRulesToAddTo'; + rules: Rule[]; + } + | { + type: 'setListType'; + listType: ExceptionListTypeEnum; + } + | { + type: 'setErrorSubmitting'; + err: Error | null; + }; + +export const createExceptionItemsReducer = + () => + (state: State, action: Action): State => { + switch (action.type) { + case 'setExceptionItemMeta': { + const { value } = action; + + return { + ...state, + exceptionItemMeta: { + ...state.exceptionItemMeta, + [value[0]]: value[1], + }, + }; + } + case 'setInitialExceptionItems': { + const { items } = action; + + return { + ...state, + initialItems: items, + }; + } + case 'setExceptionItems': { + const { items } = action; + + return { + ...state, + exceptionItems: items, + }; + } + case 'setConditionValidationErrorExists': { + const { errorExists } = action; + + return { + ...state, + itemConditionValidationErrorExists: errorExists, + }; + } + case 'setComment': { + const { comment } = action; + + return { + ...state, + newComment: comment, + }; + } + case 'setCloseSingleAlert': { + const { close } = action; + + return { + ...state, + closeSingleAlert: close, + }; + } + case 'setBulkCloseAlerts': { + const { bulkClose } = action; + + return { + ...state, + bulkCloseAlerts: bulkClose, + }; + } + case 'setBulkCloseIndex': { + const { bulkCloseIndex } = action; + + return { + ...state, + bulkCloseIndex, + }; + } + case 'setSelectedOsOptions': { + const { selectedOs } = action; + + return { + ...state, + selectedOs, + }; + } + case 'setAddExceptionToLists': { + const { listsToAddTo } = action; + + return { + ...state, + exceptionListsToAddTo: listsToAddTo, + }; + } + case 'setListOrRuleRadioOption': { + const { option } = action; + + return { + ...state, + addExceptionToRadioSelection: option, + listType: + option === 'add_to_lists' + ? ExceptionListTypeEnum.DETECTION + : ExceptionListTypeEnum.RULE_DEFAULT, + selectedRulesToAddTo: option === 'add_to_lists' ? [] : state.selectedRulesToAddTo, + }; + } + case 'setSelectedRulesToAddTo': { + const { rules } = action; + + return { + ...state, + selectedRulesToAddTo: rules, + }; + } + case 'setListType': { + const { listType } = action; + + return { + ...state, + listType, + }; + } + case 'setDisableBulkCloseAlerts': { + const { disableBulkCloseAlerts } = action; + + return { + ...state, + disableBulkClose: disableBulkCloseAlerts, + }; + } + case 'setErrorSubmitting': { + const { err } = action; + + return { + ...state, + errorSubmitting: err, + }; + } + default: + return state; + } + }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/translations.ts index fe0b31664821..eea7f90d07e5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/translations.ts @@ -7,79 +7,79 @@ import { i18n } from '@kbn/i18n'; -export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.addException.cancel', { +export const CANCEL = i18n.translate('xpack.securitySolution.ruleExceptions.addException.cancel', { defaultMessage: 'Cancel', }); -export const ADD_EXCEPTION = i18n.translate( - 'xpack.securitySolution.exceptions.addException.addException', +export const CREATE_RULE_EXCEPTION = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.createRuleExceptionLabel', { - defaultMessage: 'Add Rule Exception', + defaultMessage: 'Add rule exception', } ); export const ADD_ENDPOINT_EXCEPTION = i18n.translate( - 'xpack.securitySolution.exceptions.addException.addEndpointException', + 'xpack.securitySolution.ruleExceptions.addException.addEndpointException', { defaultMessage: 'Add Endpoint Exception', } ); -export const ADD_EXCEPTION_ERROR = i18n.translate( - 'xpack.securitySolution.exceptions.addException.error', +export const SUBMIT_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.submitError.title', { - defaultMessage: 'Failed to add exception', + defaultMessage: 'An error occured submitting exception', } ); -export const ADD_EXCEPTION_SUCCESS = i18n.translate( - 'xpack.securitySolution.exceptions.addException.success', +export const SUBMIT_ERROR_DISMISS_BUTTON = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.submitError.dismissButton', { - defaultMessage: 'Successfully added exception', + defaultMessage: 'Dismiss', } ); -export const ENDPOINT_QUARANTINE_TEXT = i18n.translate( - 'xpack.securitySolution.exceptions.addException.endpointQuarantineText', +export const SUBMIT_ERROR_DISMISS_MESSAGE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.submitError.message', { - defaultMessage: - 'On all Endpoint hosts, quarantined files that match the exception are automatically restored to their original locations. This exception applies to all rules using Endpoint exceptions.', + defaultMessage: 'View toast for error details.', } ); -export const BULK_CLOSE_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.addException.bulkCloseLabel', +export const ADD_EXCEPTION_SUCCESS = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.success', { - defaultMessage: 'Close all alerts that match this exception and were generated by this rule', + defaultMessage: 'Rule exception added to shared exception list', } ); -export const BULK_CLOSE_LABEL_DISABLED = i18n.translate( - 'xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled', - { - defaultMessage: - 'Close all alerts that match this exception and were generated by this rule (Lists and non-ECS fields are not supported)', - } -); +export const ADD_EXCEPTION_SUCCESS_DETAILS = (listNames: string) => + i18n.translate( + 'xpack.securitySolution.ruleExceptions.addExceptionFlyout.closeAlerts.successDetails', + { + values: { listNames }, + defaultMessage: 'Rule exception has been added to shared lists: {listNames}.', + } + ); -export const EXCEPTION_BUILDER_INFO = i18n.translate( - 'xpack.securitySolution.exceptions.addException.infoLabel', +export const ADD_RULE_EXCEPTION_SUCCESS_TITLE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addExceptionFlyout.addRuleExceptionToastSuccessTitle', { - defaultMessage: "Alerts are generated when the rule's conditions are met, except when:", + defaultMessage: 'Rule exception added', } ); -export const ADD_EXCEPTION_SEQUENCE_WARNING = i18n.translate( - 'xpack.securitySolution.exceptions.addException.sequenceWarning', - { - defaultMessage: - "This rule's query contains an EQL sequence statement. The exception created will apply to all events in the sequence.", - } -); +export const ADD_RULE_EXCEPTION_SUCCESS_TEXT = (ruleName: string) => + i18n.translate( + 'xpack.securitySolution.ruleExceptions.addExceptionFlyout.addRuleExceptionToastSuccessText', + { + values: { ruleName }, + defaultMessage: 'Exception has been added to rules - {ruleName}.', + } + ); -export const OPERATING_SYSTEM_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.addException.operatingSystemPlaceHolder', - { - defaultMessage: 'Select an operating system', - } -); +export const COMMENTS_SECTION_TITLE = (comments: number) => + i18n.translate('xpack.securitySolution.ruleExceptions.addExceptionFlyout.commentsTitle', { + values: { comments }, + defaultMessage: 'Add comments ({comments})', + }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/use_add_new_exceptions.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/use_add_new_exceptions.ts new file mode 100644 index 000000000000..909d8e858047 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/use_add_new_exceptions.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useCallback, useState } from 'react'; +import type { + CreateExceptionListItemSchema, + CreateRuleExceptionListItemSchema, + ExceptionListItemSchema, + ExceptionListSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { + createExceptionListItemSchema, + exceptionListItemSchema, + ExceptionListTypeEnum, + createRuleExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils'; + +import * as i18n from './translations'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; +import { useCreateOrUpdateException } from '../../logic/use_create_update_exception'; +import { useAddRuleDefaultException } from '../../logic/use_add_rule_exception'; + +export interface AddNewExceptionItemHookProps { + itemsToAdd: ExceptionsBuilderReturnExceptionItem[]; + listType: ExceptionListTypeEnum; + selectedRulesToAddTo: Rule[]; + addToSharedLists: boolean; + addToRules: boolean; + sharedLists: ExceptionListSchema[]; +} + +export type AddNewExceptionItemHookFuncProps = ( + arg: AddNewExceptionItemHookProps +) => Promise; + +export type ReturnUseAddNewExceptionItems = [boolean, AddNewExceptionItemHookFuncProps | null]; + +/** + * Hook for adding new exception items from flyout + * + */ +export const useAddNewExceptionItems = (): ReturnUseAddNewExceptionItems => { + const { addSuccess, addError, addWarning } = useAppToasts(); + const [isAddRuleExceptionLoading, addRuleExceptions] = useAddRuleDefaultException(); + const [isAddingExceptions, addSharedExceptions] = useCreateOrUpdateException(); + + const [isLoading, setIsLoading] = useState(false); + const addNewExceptionsRef = useRef(null); + + const areRuleDefaultItems = useCallback( + ( + items: ExceptionsBuilderReturnExceptionItem[] + ): items is CreateRuleExceptionListItemSchema[] => { + return items.every((item) => createRuleExceptionListItemSchema.is(item)); + }, + [] + ); + + const areSharedListItems = useCallback( + ( + items: ExceptionsBuilderReturnExceptionItem[] + ): items is Array => { + return items.every( + (item) => exceptionListItemSchema.is(item) || createExceptionListItemSchema.is(item) + ); + }, + [] + ); + + useEffect(() => { + const abortCtrl = new AbortController(); + + const addNewExceptions = async ({ + itemsToAdd, + listType, + selectedRulesToAddTo, + addToRules, + addToSharedLists, + sharedLists, + }: AddNewExceptionItemHookProps): Promise => { + try { + let result: ExceptionListItemSchema[] = []; + setIsLoading(true); + + if ( + addToRules && + addRuleExceptions != null && + listType !== ExceptionListTypeEnum.ENDPOINT && + areRuleDefaultItems(itemsToAdd) + ) { + result = await addRuleExceptions(itemsToAdd, selectedRulesToAddTo); + + const ruleNames = selectedRulesToAddTo.map(({ name }) => name).join(', '); + + addSuccess({ + title: i18n.ADD_RULE_EXCEPTION_SUCCESS_TITLE, + text: i18n.ADD_RULE_EXCEPTION_SUCCESS_TEXT(ruleNames), + }); + } else if ( + (listType === ExceptionListTypeEnum.ENDPOINT || addToSharedLists) && + addSharedExceptions != null && + areSharedListItems(itemsToAdd) + ) { + result = await addSharedExceptions(itemsToAdd); + + const sharedListNames = sharedLists.map(({ name }) => name); + + addSuccess({ + title: i18n.ADD_EXCEPTION_SUCCESS, + text: i18n.ADD_EXCEPTION_SUCCESS_DETAILS(sharedListNames.join(',')), + }); + } + + setIsLoading(false); + + return result; + } catch (e) { + setIsLoading(false); + addError(e, { title: i18n.SUBMIT_ERROR_TITLE }); + throw e; + } + }; + + addNewExceptionsRef.current = addNewExceptions; + return (): void => { + abortCtrl.abort(); + }; + }, [ + addSuccess, + addError, + addWarning, + addRuleExceptions, + addSharedExceptions, + areRuleDefaultItems, + areSharedListItems, + ]); + + return [ + isLoading || isAddingExceptions || isAddRuleExceptionLoading, + addNewExceptionsRef.current, + ]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/all_items.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/all_items.test.tsx index 0df9fad55a14..2caf7d352a73 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/all_items.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/all_items.test.tsx @@ -10,7 +10,6 @@ import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { ExceptionsViewerItems } from './all_items'; import { getMockTheme } from '../../../../common/lib/kibana/kibana_react.mock'; @@ -31,7 +30,7 @@ describe('ExceptionsViewerItems', () => { { ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-detection"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); expect(wrapper.find('[data-test-subj="exceptionsContainer"]').exists()).toBeFalsy(); }); @@ -55,7 +54,7 @@ describe('ExceptionsViewerItems', () => { { void; @@ -42,7 +39,7 @@ interface ExceptionItemsViewerProps { const ExceptionItemsViewerComponent: React.FC = ({ isReadOnly, exceptions, - listType, + isEndpoint, disableActions, ruleReferences, viewerState, @@ -55,7 +52,7 @@ const ExceptionItemsViewerComponent: React.FC = ({ {viewerState != null && viewerState !== 'deleting' ? ( @@ -68,8 +65,8 @@ const ExceptionItemsViewerComponent: React.FC = ({ { const wrapper = mount( @@ -33,7 +31,7 @@ describe('ExeptionItemsViewerEmptyPrompts', () => { const wrapper = mount( @@ -44,11 +42,11 @@ describe('ExeptionItemsViewerEmptyPrompts', () => { ).toBeTruthy(); }); - it('it renders no endpoint items screen when "currentState" is "empty" and "listType" is "endpoint"', () => { + it('it renders no endpoint items screen when "currentState" is "empty" and "isEndpoint" is "true"', () => { const wrapper = mount( @@ -61,15 +59,15 @@ describe('ExeptionItemsViewerEmptyPrompts', () => { i18n.EXCEPTION_EMPTY_PROMPT_ENDPOINT_BUTTON ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-endpoint"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); }); - it('it renders no exception items screen when "currentState" is "empty" and "listType" is "detection"', () => { + it('it renders no exception items screen when "currentState" is "empty" and "isEndpoint" is "false"', () => { const wrapper = mount( @@ -82,7 +80,7 @@ describe('ExeptionItemsViewerEmptyPrompts', () => { i18n.EXCEPTION_EMPTY_PROMPT_BUTTON ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-detection"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/empty_viewer_state.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/empty_viewer_state.tsx index 2be1860f138d..b00bf7dd7513 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/empty_viewer_state.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/empty_viewer_state.tsx @@ -15,21 +15,20 @@ import { EuiPanel, } from '@elastic/eui'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import * as i18n from './translations'; import type { ViewerState } from './reducer'; import illustration from '../../../../common/images/illustration_product_no_results_magnifying_glass.svg'; interface ExeptionItemsViewerEmptyPromptsComponentProps { isReadOnly: boolean; - listType: ExceptionListTypeEnum; + isEndpoint: boolean; currentState: ViewerState; onCreateExceptionListItem: () => void; } const ExeptionItemsViewerEmptyPromptsComponent = ({ isReadOnly, - listType, + isEndpoint, currentState, onCreateExceptionListItem, }: ExeptionItemsViewerEmptyPromptsComponentProps): JSX.Element => { @@ -60,7 +59,7 @@ const ExeptionItemsViewerEmptyPromptsComponent = ({ } body={

- {listType === ExceptionListTypeEnum.ENDPOINT + {isEndpoint ? i18n.EXCEPTION_EMPTY_ENDPOINT_PROMPT_BODY : i18n.EXCEPTION_EMPTY_PROMPT_BODY}

@@ -74,12 +73,12 @@ const ExeptionItemsViewerEmptyPromptsComponent = ({ isDisabled={isReadOnly} fill > - {listType === ExceptionListTypeEnum.ENDPOINT + {isEndpoint ? i18n.EXCEPTION_EMPTY_PROMPT_ENDPOINT_BUTTON : i18n.EXCEPTION_EMPTY_PROMPT_BUTTON} , ]} - data-test-subj={`exceptionItemViewerEmptyPrompts-empty-${listType}`} + data-test-subj="exceptionItemViewerEmptyPrompts-empty" /> ); case 'empty_search': @@ -100,7 +99,13 @@ const ExeptionItemsViewerEmptyPromptsComponent = ({ ); } - }, [currentState, euiTheme.colors.darkestShade, isReadOnly, listType, onCreateExceptionListItem]); + }, [ + currentState, + euiTheme.colors.darkestShade, + isReadOnly, + isEndpoint, + onCreateExceptionListItem, + ]); return ( { }, }); - (useFindExceptionListReferences as jest.Mock).mockReturnValue([false, null]); + (useFindExceptionListReferences as jest.Mock).mockReturnValue([ + false, + false, + { + list_id: { + _version: 'WzEzNjMzLDFd', + created_at: '2022-09-26T19:41:43.338Z', + created_by: 'elastic', + description: + 'Exception list containing exceptions for rule with id: 178c2e10-3dd3-11ed-81d7-37f31b5b97f6', + id: '3fa2c8a0-3dd3-11ed-81d7-37f31b5b97f6', + immutable: false, + list_id: 'list_id', + name: 'Exceptions for rule - My really good rule', + namespace_type: 'single', + os_types: [], + tags: ['default_rule_exception_list'], + tie_breaker_id: '83395c3e-76a0-466e-ba58-2f5a4b8b5444', + type: 'rule_default', + updated_at: '2022-09-26T19:41:43.342Z', + updated_by: 'elastic', + version: 1, + referenced_rules: [ + { + name: 'My really good rule', + id: '178c2e10-3dd3-11ed-81d7-37f31b5b97f6', + rule_id: 'cc604877-838b-438d-866b-8bce5237aa07', + exception_lists: [ + { + id: '3fa2c8a0-3dd3-11ed-81d7-37f31b5b97f6', + list_id: 'list_id', + type: 'rule_default', + namespace_type: 'single', + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); }); it('it renders loading screen when "currentState" is "loading"', () => { @@ -108,7 +148,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> @@ -146,7 +186,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> @@ -157,7 +197,7 @@ describe('ExceptionsViewer', () => { ).toBeTruthy(); }); - it('it renders no endpoint items screen when "currentState" is "empty" and "listType" is "endpoint"', () => { + it('it renders no endpoint items screen when "currentState" is "empty" and "listTypes" includes only "endpoint"', () => { (useReducer as jest.Mock).mockReturnValue([ { exceptions: [], @@ -184,7 +224,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.ENDPOINT} + listTypes={[ExceptionListTypeEnum.ENDPOINT]} isViewReadOnly={false} /> @@ -197,11 +237,11 @@ describe('ExceptionsViewer', () => { i18n.EXCEPTION_EMPTY_PROMPT_ENDPOINT_BUTTON ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-endpoint"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); }); - it('it renders no exception items screen when "currentState" is "empty" and "listType" is "detection"', () => { + it('it renders no exception items screen when "currentState" is "empty" and "listTypes" includes "detection"', () => { (useReducer as jest.Mock).mockReturnValue([ { exceptions: [], @@ -228,7 +268,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> @@ -241,7 +281,7 @@ describe('ExceptionsViewer', () => { i18n.EXCEPTION_EMPTY_PROMPT_BUTTON ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-detection"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); }); @@ -271,7 +311,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> ); @@ -305,7 +345,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx index 97de081738ff..6de23a76981b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx @@ -6,22 +6,24 @@ */ import React, { useCallback, useMemo, useEffect, useReducer } from 'react'; +import styled from 'styled-components'; + import { EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { ExceptionListItemSchema, UseExceptionListItemsSuccess, Pagination, + ExceptionListSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { transformInput } from '@kbn/securitysolution-list-hooks'; import { deleteExceptionListItemById, fetchExceptionListsItemsByListIds, } from '@kbn/securitysolution-list-api'; -import styled from 'styled-components'; -import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; + import { useUserData } from '../../../../detections/components/user_info'; import { useKibana, useToasts } from '../../../../common/lib/kibana'; import { ExceptionsViewerSearchBar } from './search_bar'; @@ -74,7 +76,7 @@ export interface GetExceptionItemProps { interface ExceptionsViewerProps { rule: Rule | null; - listType: ExceptionListTypeEnum; + listTypes: ExceptionListTypeEnum[]; /* Used for when displaying exceptions for a rule that has since been deleted, forcing read only view */ isViewReadOnly: boolean; onRuleChange?: () => void; @@ -82,7 +84,7 @@ interface ExceptionsViewerProps { const ExceptionsViewerComponent = ({ rule, - listType, + listTypes, isViewReadOnly, onRuleChange, }: ExceptionsViewerProps): JSX.Element => { @@ -92,9 +94,24 @@ const ExceptionsViewerComponent = ({ const exceptionListsToQuery = useMemo( () => rule != null && rule.exceptions_list != null - ? rule.exceptions_list.filter((list) => list.type === listType) + ? rule.exceptions_list.filter(({ type }) => + listTypes.includes(type as ExceptionListTypeEnum) + ) : [], - [listType, rule] + [listTypes, rule] + ); + const exceptionListsFormattedForReferenceQuery = useMemo( + () => + exceptionListsToQuery.map(({ id, list_id: listId, namespace_type: namespaceType }) => ({ + id, + listId, + namespaceType, + })), + [exceptionListsToQuery] + ); + const isEndpointSpecified = useMemo( + () => listTypes.length === 1 && listTypes[0] === ExceptionListTypeEnum.ENDPOINT, + [listTypes] ); // Reducer state @@ -166,13 +183,10 @@ const ExceptionsViewerComponent = ({ useFindExceptionListReferences(); useEffect(() => { - if (fetchReferences != null && exceptionListsToQuery.length) { - const listsToQuery = exceptionListsToQuery.map( - ({ id, list_id: listId, namespace_type: namespaceType }) => ({ id, listId, namespaceType }) - ); - fetchReferences(listsToQuery); + if (fetchReferences != null && exceptionListsFormattedForReferenceQuery.length) { + fetchReferences(exceptionListsFormattedForReferenceQuery); } - }, [exceptionListsToQuery, fetchReferences]); + }, [exceptionListsFormattedForReferenceQuery, fetchReferences]); useEffect(() => { if (isFetchReferencesError) { @@ -241,6 +255,7 @@ const ExceptionsViewerComponent = ({ async (options?: GetExceptionItemProps) => { try { const { pageIndex, itemsPerPage, total, data } = await handleFetchItems(options); + setViewerState(total > 0 ? null : 'empty'); setExceptions({ exceptions: data, @@ -306,15 +321,26 @@ const ExceptionsViewerComponent = ({ [setFlyoutType] ); - const handleCancelExceptionItemFlyout = useCallback((): void => { - setFlyoutType(null); - handleGetExceptionListItems(); - }, [setFlyoutType, handleGetExceptionListItems]); + const handleCancelExceptionItemFlyout = useCallback( + (didRuleChange: boolean): void => { + setFlyoutType(null); + if (didRuleChange && onRuleChange != null) { + onRuleChange(); + } + }, + [onRuleChange, setFlyoutType] + ); - const handleConfirmExceptionFlyout = useCallback((): void => { - setFlyoutType(null); - handleGetExceptionListItems(); - }, [setFlyoutType, handleGetExceptionListItems]); + const handleConfirmExceptionFlyout = useCallback( + (didRuleChange: boolean): void => { + setFlyoutType(null); + if (didRuleChange && onRuleChange != null) { + onRuleChange(); + } + handleGetExceptionListItems(); + }, + [setFlyoutType, handleGetExceptionListItems, onRuleChange] + ); const handleDeleteException = useCallback( async ({ id: itemId, name, namespaceType }: ExceptionListItemIdentifiers) => { @@ -360,49 +386,53 @@ const ExceptionsViewerComponent = ({ } }, [exceptionListsToQuery.length, handleGetExceptionListItems, setViewerState]); + const exceptionToEditList = useMemo( + (): ExceptionListSchema | null => + allReferences != null && exceptionToEdit != null + ? (allReferences[exceptionToEdit.list_id] as ExceptionListSchema) + : null, + [allReferences, exceptionToEdit] + ); + return ( <> - {currenFlyout === 'editException' && exceptionToEdit != null && rule != null && ( - - )} + {currenFlyout === 'editException' && + exceptionToEditList != null && + exceptionToEdit != null && + rule != null && ( + + )} {currenFlyout === 'addException' && rule != null && ( )} <> - {listType === ExceptionListTypeEnum.ENDPOINT - ? i18n.ENDPOINT_EXCEPTIONS_TAB_ABOUT - : i18n.EXCEPTIONS_TAB_ABOUT} + {isEndpointSpecified ? i18n.ENDPOINT_EXCEPTIONS_TAB_ABOUT : i18n.EXCEPTIONS_TAB_ABOUT} {!STATES_SEARCH_HIDDEN.includes(viewerState) && ( { it('it does not display add exception button if user is read only', () => { const wrapper = mount( { const wrapper = mount( { expect(wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"]').at(0).text()).toEqual( 'Add rule exception' ); - expect(mockOnAddExceptionClick).toHaveBeenCalledWith('detection'); + expect(mockOnAddExceptionClick).toHaveBeenCalled(); }); it('it invokes "onAddExceptionClick" when user selects to add an endpoint exception item', () => { @@ -52,7 +50,7 @@ describe('ExceptionsViewerSearchBar', () => { const wrapper = mount( { expect(wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"]').at(0).text()).toEqual( 'Add endpoint exception' ); - expect(mockOnAddExceptionClick).toHaveBeenCalledWith('endpoint'); + expect(mockOnAddExceptionClick).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/search_bar.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/search_bar.tsx index ec85567baef4..36dac931265c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/search_bar.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/search_bar.tsx @@ -8,8 +8,6 @@ import React, { useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiSearchBar } from '@elastic/eui'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import * as sharedI18n from '../../utils/translations'; import * as i18n from './translations'; import type { GetExceptionItemProps } from '.'; @@ -47,10 +45,10 @@ interface ExceptionsViewerSearchBarProps { canAddException: boolean; // Exception list type used to determine what type of item is // being created when "onAddExceptionClick" is invoked - listType: ExceptionListTypeEnum; + isEndpoint: boolean; isSearching: boolean; onSearch: (arg: GetExceptionItemProps) => void; - onAddExceptionClick: (type: ExceptionListTypeEnum) => void; + onAddExceptionClick: () => void; } /** @@ -58,7 +56,7 @@ interface ExceptionsViewerSearchBarProps { */ const ExceptionsViewerSearchBarComponent = ({ canAddException, - listType, + isEndpoint, isSearching, onSearch, onAddExceptionClick, @@ -71,14 +69,12 @@ const ExceptionsViewerSearchBarComponent = ({ ); const handleAddException = useCallback(() => { - onAddExceptionClick(listType); - }, [onAddExceptionClick, listType]); + onAddExceptionClick(); + }, [onAddExceptionClick]); const addExceptionButtonText = useMemo(() => { - return listType === ExceptionListTypeEnum.ENDPOINT - ? sharedI18n.ADD_TO_ENDPOINT_LIST - : sharedI18n.ADD_TO_DETECTIONS_LIST; - }, [listType]); + return isEndpoint ? i18n.ADD_TO_ENDPOINT_LIST : i18n.ADD_TO_DETECTIONS_LIST; + }, [isEndpoint]); return ( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/translations.ts index 221143a1e0b6..6e50d5d28fe3 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/translations.ts @@ -8,63 +8,63 @@ import { i18n } from '@kbn/i18n'; export const EXCEPTION_NO_SEARCH_RESULTS_PROMPT_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.noSearchResultsPromptTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.noSearchResultsPromptTitle', { defaultMessage: 'No results match your search criteria', } ); export const EXCEPTION_NO_SEARCH_RESULTS_PROMPT_BODY = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.noSearchResultsPromptBody', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.noSearchResultsPromptBody', { defaultMessage: 'Try modifying your search.', } ); export const EXCEPTION_EMPTY_PROMPT_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.addExceptionsEmptyPromptTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.addExceptionsEmptyPromptTitle', { defaultMessage: 'Add exceptions to this rule', } ); export const EXCEPTION_EMPTY_PROMPT_BODY = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.emptyPromptBody', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.emptyPromptBody', { defaultMessage: 'There are no exceptions for this rule. Create your first rule exception.', } ); export const EXCEPTION_EMPTY_ENDPOINT_PROMPT_BODY = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.endpoint.emptyPromptBody', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.endpoint.emptyPromptBody', { defaultMessage: 'There are no endpoint exceptions. Create your first endpoint exception.', } ); export const EXCEPTION_EMPTY_PROMPT_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.emptyPromptButtonLabel', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.emptyPromptButtonLabel', { defaultMessage: 'Add rule exception', } ); export const EXCEPTION_EMPTY_PROMPT_ENDPOINT_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.endpoint.emptyPromptButtonLabel', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.endpoint.emptyPromptButtonLabel', { defaultMessage: 'Add endpoint exception', } ); export const EXCEPTION_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemsFetchError', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemsFetchError', { defaultMessage: 'Unable to load exception items', } ); export const EXCEPTION_ERROR_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemsFetchErrorDescription', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemsFetchErrorDescription', { defaultMessage: 'There was an error loading the exception items. Contact your administrator for help.', @@ -72,48 +72,51 @@ export const EXCEPTION_ERROR_DESCRIPTION = i18n.translate( ); export const EXCEPTION_SEARCH_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemSearchErrorTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemSearchErrorTitle', { defaultMessage: 'Error searching', } ); export const EXCEPTION_SEARCH_ERROR_BODY = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemSearchErrorBody', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemSearchErrorBody', { defaultMessage: 'An error occurred searching for exception items. Please try again.', } ); export const EXCEPTION_DELETE_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionDeleteErrorTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionDeleteErrorTitle', { defaultMessage: 'Error deleting exception item', } ); export const EXCEPTION_ITEMS_PAGINATION_ARIA_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.paginationAriaLabel', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.paginationAriaLabel', { defaultMessage: 'Exception item table pagination', } ); export const EXCEPTION_ITEM_DELETE_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemDeleteSuccessTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemDeleteSuccessTitle', { defaultMessage: 'Exception deleted', } ); export const EXCEPTION_ITEM_DELETE_TEXT = (itemName: string) => - i18n.translate('xpack.securitySolution.exceptions.allItems.exceptionItemDeleteSuccessText', { - values: { itemName }, - defaultMessage: '"{itemName}" deleted successfully.', - }); + i18n.translate( + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemDeleteSuccessText', + { + values: { itemName }, + defaultMessage: '"{itemName}" deleted successfully.', + } + ); export const ENDPOINT_EXCEPTIONS_TAB_ABOUT = i18n.translate( - 'xpack.securitySolution.exceptions.allExceptionItems.exceptionEndpointDetailsDescription', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionEndpointDetailsDescription', { defaultMessage: 'Endpoint exceptions are added to both the detection rule and the Elastic Endpoint agent on your hosts.', @@ -121,15 +124,29 @@ export const ENDPOINT_EXCEPTIONS_TAB_ABOUT = i18n.translate( ); export const EXCEPTIONS_TAB_ABOUT = i18n.translate( - 'xpack.securitySolution.exceptions.allExceptionItems.exceptionDetectionDetailsDescription', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionDetectionDetailsDescription', { defaultMessage: 'Rule exceptions are added to the detection rule.', } ); export const SEARCH_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.allExceptionItems.searchPlaceholder', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.searchPlaceholder', { defaultMessage: 'Filter exceptions using simple query syntax, for example, name:"my list"', } ); + +export const ADD_TO_ENDPOINT_LIST = i18n.translate( + 'xpack.securitySolution.ruleExceptions.allExceptionItems.addToEndpointListLabel', + { + defaultMessage: 'Add endpoint exception', + } +); + +export const ADD_TO_DETECTIONS_LIST = i18n.translate( + 'xpack.securitySolution.ruleExceptions.allExceptionItems.addToDetectionsListLabel', + { + defaultMessage: 'Add rule exception', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.test.tsx index aa604dbfbf00..d9fe0218311c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.test.tsx @@ -6,14 +6,14 @@ */ import React from 'react'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { mount } from 'enzyme'; import { ExceptionsViewerUtility } from './utility_bar'; import { TestProviders } from '../../../../common/mock'; describe('ExceptionsViewerUtility', () => { it('it renders correct item counts', () => { - const wrapper = mountWithIntl( + const wrapper = mount( { }); it('it renders last updated message', () => { - const wrapper = mountWithIntl( + const wrapper = mount( >; const mockUseSignalIndex = useSignalIndex as jest.Mock>>; -const mockUseAddOrUpdateException = useAddOrUpdateException as jest.Mock< - ReturnType ->; const mockUseFetchIndex = useFetchIndex as jest.Mock; const mockUseCurrentUser = useCurrentUser as jest.Mock>>; -const mockUseRuleAsync = useRuleAsync as jest.Mock; +const mockFetchIndexPatterns = useFetchIndexPatterns as jest.Mock< + ReturnType +>; +const mockUseAddOrUpdateException = useCreateOrUpdateException as jest.Mock< + ReturnType +>; +const mockUseFindExceptionListReferences = useFindExceptionListReferences as jest.Mock; describe('When the edit exception modal is opened', () => { - const ruleName = 'test rule'; - beforeEach(() => { const emptyComp = ; mockGetExceptionBuilderComponentLazy.mockReturnValue(emptyComp); @@ -66,19 +76,42 @@ describe('When the edit exception modal is opened', () => { loading: false, signalIndexName: 'test-signal', }); - mockUseAddOrUpdateException.mockImplementation(() => [{ isLoading: false }, jest.fn()]); + mockUseAddOrUpdateException.mockImplementation(() => [false, jest.fn()]); mockUseFetchIndex.mockImplementation(() => [ false, { indexPatterns: createStubIndexPattern({ spec: { id: '1234', - title: 'logstash-*', + title: 'filebeat-*', fields: { - response: { - name: 'response', - type: 'number', - esTypes: ['integer'], + 'event.code': { + name: 'event.code', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.path.caseless': { + name: 'file.path.caseless', + type: 'string', + aggregatable: true, + searchable: true, + }, + subject_name: { + name: 'subject_name', + type: 'string', + aggregatable: true, + searchable: true, + }, + trusted: { + name: 'trusted', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.hash.sha256': { + name: 'file.hash.sha256', + type: 'string', aggregatable: true, searchable: true, }, @@ -88,9 +121,40 @@ describe('When the edit exception modal is opened', () => { }, ]); mockUseCurrentUser.mockReturnValue({ username: 'test-username' }); - mockUseRuleAsync.mockImplementation(() => ({ - rule: getRulesSchemaMock(), + mockFetchIndexPatterns.mockImplementation(() => ({ + isLoading: false, + indexPatterns: stubIndexPattern, })); + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + my_list_id: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.DETECTION, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: '1234', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.DETECTION, + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); }); afterEach(() => { @@ -100,24 +164,22 @@ describe('When the edit exception modal is opened', () => { describe('when the modal is loading', () => { it('renders the loading spinner', async () => { - mockUseFetchIndex.mockImplementation(() => [ - true, - { - indexPatterns: stubIndexPattern, - }, - ]); + // Mocks one of the hooks as loading + mockFetchIndexPatterns.mockImplementation(() => ({ + isLoading: true, + indexPatterns: { fields: [], title: 'foo' }, + })); + const wrapper = mount( - + - + ); await waitFor(() => { expect(wrapper.find('[data-test-subj="loadingEditExceptionFlyout"]').exists()).toBeTruthy(); @@ -125,69 +187,198 @@ describe('When the edit exception modal is opened', () => { }); }); - describe('when an endpoint exception with exception data is passed', () => { - describe('when exception entry fields are included in the index pattern', () => { + describe('exception list type of "endpoint"', () => { + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + endpoint_list: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'endpoint_list', + namespace_type: 'agnostic', + type: ExceptionListTypeEnum.ENDPOINT, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: 'endpoint_list', + list_id: 'endpoint_list', + namespace_type: 'single', + type: ExceptionListTypeEnum.ENDPOINT, + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); + + describe('common functionality to test', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.EDIT_ENDPOINT_EXCEPTION_TITLE + ); + expect(wrapper.find('[data-test-subj="editExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.EDIT_ENDPOINT_EXCEPTION_TITLE + ); + }); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); + }); + + it('should render OS info', () => { + expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').exists()).toBeTruthy(); + }); + + it('should render the exception builder', () => { + expect(wrapper.find('ExceptionsConditions').exists()).toBeTruthy(); + }); + + it('does NOT render section showing list or rule item assigned to', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemLinkedToListSection"]').exists() + ).toBeFalsy(); + expect( + wrapper.find('[data-test-subj="exceptionItemLinkedToRuleSection"]').exists() + ).toBeFalsy(); + }); + + it('should contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeTruthy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).not.toBeTruthy(); + }); + }); + + describe('when exception entry fields and index allow user to bulk close', () => { let wrapper: ReactWrapper; beforeEach(async () => { const exceptionItemMock = { ...getExceptionListItemSchemaMock(), entries: [ - { field: 'response', operator: 'included', type: 'match', value: '3' }, + { field: 'file.hash.sha256', operator: 'included', type: 'match' }, ] as EntriesArray, }; wrapper = mount( ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => { - callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); - }); - it('has the edit exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); }); + it('should have the bulk close checkbox enabled', () => { expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() ).not.toBeDisabled(); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); - }); - it('should contain the endpoint specific documentation text', () => { - expect( - wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists() - ).toBeTruthy(); - }); }); - describe("when exception entry fields aren't included in the index pattern", () => { + describe('when entry has non ecs type', () => { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( ); @@ -196,182 +387,310 @@ describe('When the edit exception modal is opened', () => { callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); }); }); - it('has the edit exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); - }); + it('should have the bulk close checkbox disabled', () => { expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() ).toBeDisabled(); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); - }); - it('should contain the endpoint specific documentation text', () => { - expect( - wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists() - ).toBeTruthy(); - }); }); }); - describe('when an exception assigned to a sequence eql rule type is passed', () => { + describe('exception list type of "detection"', () => { let wrapper: ReactWrapper; beforeEach(async () => { - (useRuleAsync as jest.Mock).mockImplementation(() => ({ - rule: { - ...getRulesEqlSchemaMock(), - query: - 'sequence [process where process.name = "test.exe"] [process where process.name = "explorer.exe"]', - }, - })); wrapper = mount( - + - + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) ); - const callProps = (getExceptionBuilderComponentLazy as jest.Mock).mock.calls[0][0]; - await waitFor(() => { - callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); }); - it('has the edit exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.EDIT_EXCEPTION_TITLE + ); + expect(wrapper.find('[data-test-subj="editExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.EDIT_EXCEPTION_TITLE + ); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); }); - it('should not contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists()).toBeFalsy(); + + it('should not render OS info', () => { + expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').exists()).toBeFalsy(); }); - it('should have the bulk close checkbox disabled', () => { + + it('should render the exception builder', () => { + expect(wrapper.find('ExceptionsConditions').exists()).toBeTruthy(); + }); + + it('does render section showing list item is assigned to', () => { expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() - ).toBeDisabled(); + wrapper.find('[data-test-subj="exceptionItemLinkedToListSection"]').exists() + ).toBeTruthy(); }); - it('should display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).toBeTruthy(); + + it('does NOT render section showing rule item is assigned to', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemLinkedToRuleSection"]').exists() + ).toBeFalsy(); + }); + + it('should NOT contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeFalsy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeFalsy(); }); }); - describe('when a detection exception with entries is passed', () => { + describe('exception list type of "rule_default"', () => { + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + my_list_id: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.RULE_DEFAULT, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: '1234', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.RULE_DEFAULT, + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); + let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( - + - + ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => { - callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); }); - it('has the edit exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.EDIT_EXCEPTION_TITLE + ); + expect(wrapper.find('[data-test-subj="editExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.EDIT_EXCEPTION_TITLE + ); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); }); - it('should not contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists()).toBeFalsy(); + + it('should not render OS info', () => { + expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').exists()).toBeFalsy(); }); - it('should have the bulk close checkbox disabled', () => { + + it('should render the exception builder', () => { + expect(wrapper.find('ExceptionsConditions').exists()).toBeTruthy(); + }); + + it('does NOT render section showing list item is assigned to', () => { expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() - ).toBeDisabled(); + wrapper.find('[data-test-subj="exceptionItemLinkedToListSection"]').exists() + ).toBeFalsy(); + }); + + it('does render section showing rule item is assigned to', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemLinkedToRuleSection"]').exists() + ).toBeTruthy(); }); - it('should not display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy(); + + it('should NOT contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeFalsy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeFalsy(); }); }); - describe('when an exception with no entries is passed', () => { + describe('when an exception assigned to a sequence eql rule type is passed', () => { let wrapper: ReactWrapper; beforeEach(async () => { - const exceptionItemMock = { ...getExceptionListItemSchemaMock(), entries: [] }; wrapper = mount( - + - + ); - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + const callProps = (getExceptionBuilderComponentLazy as jest.Mock).mock.calls[0][0]; await waitFor(() => { callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); }); }); - it('has the edit exception button disabled', () => { + + it('should have the bulk close checkbox disabled', () => { expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() ).toBeDisabled(); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); + + it('should display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeTruthy(); }); - it('should have the bulk close checkbox disabled', () => { + }); + + describe('error states', () => { + test('when there are exception builder errors has submit button disabled', async () => { + const wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); + expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() + wrapper.find('button[data-test-subj="editExceptionConfirmButton"]').getDOMNode() ).toBeDisabled(); }); }); - - test('when there are exception builder errors has the add exception button disabled', async () => { - const wrapper = mount( - - - - ); - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); - - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).toBeDisabled(); - }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx index 85a59f06281f..d6dbc402a92e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx @@ -5,75 +5,61 @@ * 2.0. */ -// Component being re-implemented in 8.5 - -/* eslint-disable complexity */ - -import React, { memo, useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import styled, { css } from 'styled-components'; import { EuiButton, EuiButtonEmpty, EuiHorizontalRule, - EuiCheckbox, EuiSpacer, - EuiFormRow, - EuiText, - EuiCallOut, EuiFlyoutHeader, EuiFlyoutBody, EuiFlexGroup, EuiTitle, EuiFlyout, EuiFlyoutFooter, + EuiLoadingContent, } from '@elastic/eui'; import type { - ExceptionListType, - OsTypeArray, - OsType, ExceptionListItemSchema, - CreateExceptionListItemSchema, + ExceptionListSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { + ExceptionListTypeEnum, + exceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; -import type { DataViewBase } from '@kbn/es-query'; +import { isEmpty } from 'lodash/fp'; import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils'; -import { useRuleIndices } from '../../../../detections/containers/detection_engine/rules/use_rule_indices'; -import { hasEqlSequenceQuery, isEqlRule } from '../../../../../common/detection_engine/utils'; -import { useFetchIndex } from '../../../../common/containers/source'; -import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; -import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; - import * as i18n from './translations'; -import * as sharedI18n from '../../utils/translations'; -import { useKibana } from '../../../../common/lib/kibana'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { useAddOrUpdateException } from '../../logic/use_add_exception'; -import { ExceptionItemComments } from '../item_comments'; +import { ExceptionsFlyoutMeta } from '../flyout_components/item_meta_form'; +import { createExceptionItemsReducer } from './reducer'; +import { ExceptionsLinkedToLists } from '../flyout_components/linked_to_list'; +import { ExceptionsLinkedToRule } from '../flyout_components/linked_to_rule'; +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; +import { ExceptionItemsFlyoutAlertsActions } from '../flyout_components/alerts_actions'; +import { ExceptionsConditions } from '../flyout_components/item_conditions'; import { - enrichExistingExceptionItemWithComments, - enrichExceptionItemsWithOS, - entryHasListType, - entryHasNonEcsType, - lowercaseHashValues, - filterIndexPatterns, -} from '../../utils/helpers'; -import { Loader } from '../../../../common/components/loader'; -import type { ErrorInfo } from '../error_callout'; -import { ErrorCallout } from '../error_callout'; -import { ruleTypesThatAllowLargeValueLists } from '../../utils/constants'; + isEqlRule, + isNewTermsRule, + isThresholdRule, +} from '../../../../../common/detection_engine/utils'; +import { useFetchIndexPatterns } from '../../logic/use_exception_flyout_data'; +import { filterIndexPatterns } from '../../utils/helpers'; +import { entrichExceptionItemsForUpdate } from '../flyout_components/utils'; +import { useEditExceptionItems } from './use_edit_exception'; +import { useCloseAlertsFromExceptions } from '../../logic/use_close_alerts'; +import { useFindExceptionListReferences } from '../../logic/use_find_references'; +import { ExceptionItemComments } from '../item_comments'; interface EditExceptionFlyoutProps { - ruleName: string; - ruleId: string; - ruleIndices: string[]; - dataViewId?: string; - exceptionItem: ExceptionListItemSchema; - exceptionListType: ExceptionListType; - onCancel: () => void; - onConfirm: () => void; - onRuleChange?: () => void; + list: ExceptionListSchema; + itemToEdit: ExceptionListItemSchema; + showAlertCloseOptions: boolean; + rule?: Rule; + onCancel: (arg: boolean) => void; + onConfirm: (arg: boolean) => void; } const FlyoutHeader = styled(EuiFlyoutHeader)` @@ -82,412 +68,335 @@ const FlyoutHeader = styled(EuiFlyoutHeader)` `} `; -const FlyoutSubtitle = styled.div` - ${({ theme }) => css` - color: ${theme.eui.euiColorMediumShade}; +const FlyoutBodySection = styled(EuiFlyoutBody)` + ${() => css` + &.builder-section { + overflow-y: scroll; + } `} `; -const FlyoutBodySection = styled.section` +const FlyoutFooterGroup = styled(EuiFlexGroup)` ${({ theme }) => css` - padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; + padding: ${theme.eui.euiSizeS}; `} `; -const FlyoutCheckboxesSection = styled.section` - overflow-y: inherit; - height: auto; - .euiFlyoutBody__overflowContent { - padding-top: 0; - } -`; - -const FlyoutFooterGroup = styled(EuiFlexGroup)` - ${({ theme }) => css` - padding: ${theme.eui.euiSizeS}; +const SectionHeader = styled(EuiTitle)` + ${() => css` + font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; `} `; -export const EditExceptionFlyout = memo(function EditExceptionFlyout({ - ruleName, - ruleId, - ruleIndices, - dataViewId, - exceptionItem, - exceptionListType, +const EditExceptionFlyoutComponent: React.FC = ({ + list, + itemToEdit, + rule, + showAlertCloseOptions, onCancel, onConfirm, - onRuleChange, -}: EditExceptionFlyoutProps) { - const { http, unifiedSearch, data } = useKibana().services; - const [comment, setComment] = useState(''); - const [errorsExist, setErrorExists] = useState(false); - const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId); - const [updateError, setUpdateError] = useState(null); - const [hasVersionConflict, setHasVersionConflict] = useState(false); - const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); - const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); - const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState< - ExceptionsBuilderReturnExceptionItem[] - >([]); - const { addError, addSuccess } = useAppToasts(); - const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); - const memoSignalIndexName = useMemo( - () => (signalIndexName !== null ? [signalIndexName] : []), - [signalIndexName] - ); - const [isSignalIndexPatternLoading, { indexPatterns: signalIndexPatterns }] = - useFetchIndex(memoSignalIndexName); - - const { mlJobLoading, ruleIndices: memoRuleIndices } = useRuleIndices( - maybeRule?.machine_learning_job_id, - ruleIndices - ); +}): JSX.Element => { + const selectedOs = useMemo(() => itemToEdit.os_types, [itemToEdit]); + const rules = useMemo(() => (rule != null ? [rule] : null), [rule]); + const listType = useMemo((): ExceptionListTypeEnum => list.type as ExceptionListTypeEnum, [list]); - const hasDataViewId = dataViewId || maybeRule?.data_view_id || null; - const [dataViewIndexPatterns, setDataViewIndexPatterns] = useState(null); + const { isLoading, indexPatterns } = useFetchIndexPatterns(rules); + const [isSubmitting, submitEditExceptionItems] = useEditExceptionItems(); + const [isClosingAlerts, closeAlerts] = useCloseAlertsFromExceptions(); - useEffect(() => { - const fetchSingleDataView = async () => { - if (hasDataViewId) { - const dv = await data.dataViews.get(hasDataViewId); - setDataViewIndexPatterns(dv); - } - }; - - fetchSingleDataView(); - }, [hasDataViewId, data.dataViews, setDataViewIndexPatterns]); - - // Don't fetch indices if rule has data view id (currently rule can technically have - // both defined and in that case we'd be doing unnecessary work here if all we want is - // the data view fields) - const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] = useFetchIndex( - hasDataViewId ? [] : memoRuleIndices - ); + const [ + { + exceptionItems, + exceptionItemMeta: { name: exceptionItemName }, + newComment, + bulkCloseAlerts, + disableBulkClose, + bulkCloseIndex, + entryErrorExists, + }, + dispatch, + ] = useReducer(createExceptionItemsReducer(), { + exceptionItems: [itemToEdit], + exceptionItemMeta: { name: itemToEdit.name }, + newComment: '', + bulkCloseAlerts: false, + disableBulkClose: true, + bulkCloseIndex: undefined, + entryErrorExists: false, + }); + + const allowLargeValueLists = useMemo((): boolean => { + if (rule != null) { + // We'll only block this when we know what rule we're dealing with. + // When editing an item outside the context of a specific rule, + // we won't block but should communicate to the user that large value lists + // won't be applied to all rule types. + return !isEqlRule(rule.type) && !isThresholdRule(rule.type) && !isNewTermsRule(rule.type); + } else { + return true; + } + }, [rule]); - const indexPattern = useMemo( - (): DataViewBase | null => (hasDataViewId ? dataViewIndexPatterns : indexIndexPatterns), - [hasDataViewId, dataViewIndexPatterns, indexIndexPatterns] - ); + const [isLoadingReferences, referenceFetchError, ruleReferences, fetchReferences] = + useFindExceptionListReferences(); - const handleExceptionUpdateError = useCallback( - (error: Error, statusCode: number | null, message: string | null) => { - if (error.message.includes('Conflict')) { - setHasVersionConflict(true); - } else { - setUpdateError({ - reason: error.message, - code: statusCode, - details: message, - listListId: exceptionItem.list_id, - }); - } + useEffect(() => { + if (fetchReferences != null) { + fetchReferences([ + { + id: list.id, + listId: list.list_id, + namespaceType: list.namespace_type, + }, + ]); + } + }, [list, fetchReferences]); + + /** + * Reducer action dispatchers + * */ + const setExceptionItemsToAdd = useCallback( + (items: ExceptionsBuilderReturnExceptionItem[]): void => { + dispatch({ + type: 'setExceptionItems', + items, + }); }, - [setUpdateError, setHasVersionConflict, exceptionItem.list_id] + [dispatch] ); - const handleDissasociationSuccess = useCallback( - (id: string): void => { - addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); - - if (onRuleChange) { - onRuleChange(); - } - - onCancel(); + const setExceptionItemMeta = useCallback( + (value: [string, string]): void => { + dispatch({ + type: 'setExceptionItemMeta', + value, + }); }, - [addSuccess, onCancel, onRuleChange] + [dispatch] ); - const handleDissasociationError = useCallback( - (error: Error): void => { - addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); - onCancel(); + const setComment = useCallback( + (comment: string): void => { + dispatch({ + type: 'setComment', + comment, + }); }, - [addError, onCancel] + [dispatch] ); - const handleExceptionUpdateSuccess = useCallback((): void => { - addSuccess(i18n.EDIT_EXCEPTION_SUCCESS); - onConfirm(); - }, [addSuccess, onConfirm]); - - const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( - { - http, - onSuccess: handleExceptionUpdateSuccess, - onError: handleExceptionUpdateError, - } + const setBulkCloseAlerts = useCallback( + (bulkClose: boolean): void => { + dispatch({ + type: 'setBulkCloseAlerts', + bulkClose, + }); + }, + [dispatch] ); - useEffect(() => { - if (isSignalIndexPatternLoading === false && isSignalIndexLoading === false) { - setShouldDisableBulkClose( - entryHasListType(exceptionItemsToAdd) || - entryHasNonEcsType(exceptionItemsToAdd, signalIndexPatterns) || - exceptionItemsToAdd.every((item) => item.entries.length === 0) - ); - } - }, [ - setShouldDisableBulkClose, - exceptionItemsToAdd, - isSignalIndexPatternLoading, - isSignalIndexLoading, - signalIndexPatterns, - ]); - - useEffect(() => { - if (shouldDisableBulkClose === true) { - setShouldBulkCloseAlert(false); - } - }, [shouldDisableBulkClose]); - - const isSubmitButtonDisabled = useMemo( - () => - exceptionItemsToAdd.every((item) => item.entries.length === 0) || - hasVersionConflict || - errorsExist, - [exceptionItemsToAdd, hasVersionConflict, errorsExist] + const setDisableBulkCloseAlerts = useCallback( + (disableBulkCloseAlerts: boolean): void => { + dispatch({ + type: 'setDisableBulkCloseAlerts', + disableBulkCloseAlerts, + }); + }, + [dispatch] ); - const handleBuilderOnChange = useCallback( - ({ - exceptionItems, - errorExists, - }: { - exceptionItems: ExceptionsBuilderReturnExceptionItem[]; - errorExists: boolean; - }) => { - setExceptionItemsToAdd(exceptionItems); - setErrorExists(errorExists); + const setBulkCloseIndex = useCallback( + (index: string[] | undefined): void => { + dispatch({ + type: 'setBulkCloseIndex', + bulkCloseIndex: index, + }); }, - [setExceptionItemsToAdd] + [dispatch] ); - const onCommentChange = useCallback( - (value: string) => { - setComment(value); + const setConditionsValidationError = useCallback( + (errorExists: boolean): void => { + dispatch({ + type: 'setConditionValidationErrorExists', + errorExists, + }); }, - [setComment] + [dispatch] ); - const onBulkCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent) => { - setShouldBulkCloseAlert(event.currentTarget.checked); + const handleCloseFlyout = useCallback((): void => { + onCancel(false); + }, [onCancel]); + + const areItemsReadyForUpdate = useCallback( + (items: ExceptionsBuilderReturnExceptionItem[]): items is ExceptionListItemSchema[] => { + return items.every((item) => exceptionListItemSchema.is(item)); }, - [setShouldBulkCloseAlert] + [] ); - const enrichExceptionItems = useCallback(() => { - const [exceptionItemToEdit] = exceptionItemsToAdd; - let enriched: ExceptionsBuilderReturnExceptionItem[] = [ - { - ...enrichExistingExceptionItemWithComments(exceptionItemToEdit, [ - ...exceptionItem.comments, - ...(comment.trim() !== '' ? [{ comment }] : []), - ]), - }, - ]; - if (exceptionListType === 'endpoint') { - enriched = lowercaseHashValues(enrichExceptionItemsWithOS(enriched, exceptionItem.os_types)); - } - return enriched; - }, [exceptionItemsToAdd, exceptionItem, comment, exceptionListType]); - - const onEditExceptionConfirm = useCallback(() => { - if (addOrUpdateExceptionItems !== null) { - const bulkCloseIndex = - shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined; - addOrUpdateExceptionItems( - maybeRule?.rule_id ?? '', - // This is being rewritten in https://github.com/elastic/kibana/pull/140643 - // As of now, flyout cannot yet create item of type CreateRuleExceptionListItemSchema - enrichExceptionItems() as Array, - undefined, - bulkCloseIndex - ); + const handleSubmit = useCallback(async (): Promise => { + if (submitEditExceptionItems == null) return; + + try { + const items = entrichExceptionItemsForUpdate({ + itemName: exceptionItemName, + commentToAdd: newComment, + listType, + selectedOs: itemToEdit.os_types, + items: exceptionItems, + }); + + if (areItemsReadyForUpdate(items)) { + await submitEditExceptionItems({ + itemsToUpdate: items, + }); + + const ruleDefaultRule = rule != null ? [rule.rule_id] : []; + const referencedRules = + ruleReferences != null + ? ruleReferences[list.list_id].referenced_rules.map(({ rule_id: ruleId }) => ruleId) + : []; + const ruleIdsForBulkClose = + listType === ExceptionListTypeEnum.RULE_DEFAULT ? ruleDefaultRule : referencedRules; + + if (closeAlerts != null && !isEmpty(ruleIdsForBulkClose) && bulkCloseAlerts) { + await closeAlerts(ruleIdsForBulkClose, items, undefined, bulkCloseIndex); + } + + onConfirm(true); + } + } catch (e) { + onCancel(false); } }, [ - addOrUpdateExceptionItems, - maybeRule, - enrichExceptionItems, - shouldBulkCloseAlert, - signalIndexName, + submitEditExceptionItems, + exceptionItemName, + newComment, + listType, + itemToEdit.os_types, + exceptionItems, + areItemsReadyForUpdate, + rule, + ruleReferences, + list.list_id, + closeAlerts, + bulkCloseAlerts, + onConfirm, + bulkCloseIndex, + onCancel, ]); - const isRuleEQLSequenceStatement = useMemo((): boolean => { - if (maybeRule != null) { - return isEqlRule(maybeRule.type) && hasEqlSequenceQuery(maybeRule.query); - } - return false; - }, [maybeRule]); - - const osDisplay = (osTypes: OsTypeArray): string => { - const translateOS = (currentOs: OsType): string => { - return currentOs === 'linux' - ? sharedI18n.OPERATING_SYSTEM_LINUX - : currentOs === 'macos' - ? sharedI18n.OPERATING_SYSTEM_MAC - : sharedI18n.OPERATING_SYSTEM_WINDOWS; - }; - return osTypes - .reduce((osString, currentOs) => { - return `${translateOS(currentOs)}, ${osString}`; - }, '') - .slice(0, -2); - }; - - const allowLargeValueLists = useMemo( - () => (maybeRule != null ? ruleTypesThatAllowLargeValueLists.includes(maybeRule.type) : false), - [maybeRule] + const editExceptionMessage = useMemo( + () => + listType === ExceptionListTypeEnum.ENDPOINT + ? i18n.EDIT_ENDPOINT_EXCEPTION_TITLE + : i18n.EDIT_EXCEPTION_TITLE, + [listType] + ); + + const isSubmitButtonDisabled = useMemo( + () => + isSubmitting || + isClosingAlerts || + exceptionItems.every((item) => item.entries.length === 0) || + isLoading || + entryErrorExists, + [isLoading, entryErrorExists, exceptionItems, isSubmitting, isClosingAlerts] ); return ( - + -

- {exceptionListType === 'endpoint' - ? i18n.EDIT_ENDPOINT_EXCEPTION_TITLE - : i18n.EDIT_EXCEPTION_TITLE} -

+

{editExceptionMessage}

- -
- {(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && ( - - )} - {!isSignalIndexLoading && - indexPattern != null && - !addExceptionIsLoading && - !isIndexPatternLoading && - !isRuleLoading && - !mlJobLoading && ( - - - {isRuleEQLSequenceStatement && ( - <> - - - - )} - {i18n.EXCEPTION_BUILDER_INFO} - - {exceptionListType === 'endpoint' && ( - <> - -
-
{sharedI18n.OPERATING_SYSTEM_LABEL}
-
{osDisplay(exceptionItem.os_types)}
-
-
- - - )} - {getExceptionBuilderComponentLazy({ - allowLargeValueLists, - httpService: http, - autocompleteService: unifiedSearch.autocomplete, - exceptionListItems: [exceptionItem], - listType: exceptionListType, - listId: exceptionItem.list_id, - listNamespaceType: exceptionItem.namespace_type, - listTypeSpecificIndexPatternFilter: filterIndexPatterns, - ruleName, - isOrDisabled: true, - isAndDisabled: false, - osTypes: exceptionItem.os_types, - isNestedDisabled: false, - dataTestSubj: 'edit-exception-builder', - idAria: 'edit-exception-builder', - onChange: handleBuilderOnChange, - indexPatterns: indexPattern, - })} - - - - -
+ {isLoading && } + + + + + {listType === ExceptionListTypeEnum.DETECTION && ( + <> - - - - - {exceptionListType === 'endpoint' && ( - <> - - - {i18n.ENDPOINT_QUARANTINE_TEXT} - - - )} - -
+ + )} - - - {hasVersionConflict && ( + {listType === ExceptionListTypeEnum.RULE_DEFAULT && rule != null && ( <> - -

{i18n.VERSION_CONFLICT_ERROR_DESCRIPTION}

-
- + + )} - {updateError != null && ( + + +

{i18n.COMMENTS_SECTION_TITLE(itemToEdit.comments.length ?? 0)}

+ + } + exceptionItemComments={itemToEdit.comments} + newCommentValue={newComment} + newCommentOnChange={setComment} + /> + {showAlertCloseOptions && ( <> - + - )} - {updateError === null && ( - - - {i18n.CANCEL} - - - - {i18n.EDIT_EXCEPTION_SAVE_BUTTON} - - - )} + + + + + {i18n.CANCEL} + + + + {editExceptionMessage} + +
); -}); +}; + +export const EditExceptionFlyout = React.memo(EditExceptionFlyoutComponent); + +EditExceptionFlyout.displayName = 'EditExceptionFlyout'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts new file mode 100644 index 000000000000..22fefe760e4a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils'; + +export interface State { + exceptionItems: ExceptionsBuilderReturnExceptionItem[]; + exceptionItemMeta: { name: string }; + newComment: string; + bulkCloseAlerts: boolean; + disableBulkClose: boolean; + bulkCloseIndex: string[] | undefined; + entryErrorExists: boolean; +} + +export type Action = + | { + type: 'setExceptionItemMeta'; + value: [string, string]; + } + | { + type: 'setComment'; + comment: string; + } + | { + type: 'setBulkCloseAlerts'; + bulkClose: boolean; + } + | { + type: 'setDisableBulkCloseAlerts'; + disableBulkCloseAlerts: boolean; + } + | { + type: 'setBulkCloseIndex'; + bulkCloseIndex: string[] | undefined; + } + | { + type: 'setExceptionItems'; + items: ExceptionsBuilderReturnExceptionItem[]; + } + | { + type: 'setConditionValidationErrorExists'; + errorExists: boolean; + }; + +export const createExceptionItemsReducer = + () => + (state: State, action: Action): State => { + switch (action.type) { + case 'setExceptionItemMeta': { + const { value } = action; + + return { + ...state, + exceptionItemMeta: { + ...state.exceptionItemMeta, + [value[0]]: value[1], + }, + }; + } + case 'setComment': { + const { comment } = action; + + return { + ...state, + newComment: comment, + }; + } + case 'setBulkCloseAlerts': { + const { bulkClose } = action; + + return { + ...state, + bulkCloseAlerts: bulkClose, + }; + } + case 'setDisableBulkCloseAlerts': { + const { disableBulkCloseAlerts } = action; + + return { + ...state, + disableBulkClose: disableBulkCloseAlerts, + }; + } + case 'setBulkCloseIndex': { + const { bulkCloseIndex } = action; + + return { + ...state, + bulkCloseIndex, + }; + } + case 'setExceptionItems': { + const { items } = action; + + return { + ...state, + exceptionItems: items, + }; + } + case 'setConditionValidationErrorExists': { + const { errorExists } = action; + + return { + ...state, + entryErrorExists: errorExists, + }; + } + default: + return state; + } + }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/translations.ts index 6a5fd6f44810..9839fa5ddc9d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/translations.ts @@ -7,87 +7,50 @@ import { i18n } from '@kbn/i18n'; -export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.editException.cancel', { +export const CANCEL = i18n.translate('xpack.securitySolution.ruleExceptions.editException.cancel', { defaultMessage: 'Cancel', }); -export const EDIT_EXCEPTION_SAVE_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.editException.editExceptionSaveButton', - { - defaultMessage: 'Save', - } -); - export const EDIT_EXCEPTION_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.editException.editExceptionTitle', + 'xpack.securitySolution.ruleExceptions.editException.editExceptionTitle', { - defaultMessage: 'Edit Rule Exception', + defaultMessage: 'Edit rule exception', } ); export const EDIT_ENDPOINT_EXCEPTION_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle', - { - defaultMessage: 'Edit Endpoint Exception', - } -); - -export const EDIT_EXCEPTION_SUCCESS = i18n.translate( - 'xpack.securitySolution.exceptions.editException.success', - { - defaultMessage: 'Successfully updated exception', - } -); - -export const BULK_CLOSE_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.editException.bulkCloseLabel', - { - defaultMessage: 'Close all alerts that match this exception and were generated by this rule', - } -); - -export const BULK_CLOSE_LABEL_DISABLED = i18n.translate( - 'xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled', + 'xpack.securitySolution.ruleExceptions.editException.editEndpointExceptionTitle', { - defaultMessage: - 'Close all alerts that match this exception and were generated by this rule (Lists and non-ECS fields are not supported)', + defaultMessage: 'Edit endpoint exception', } ); -export const ENDPOINT_QUARANTINE_TEXT = i18n.translate( - 'xpack.securitySolution.exceptions.editException.endpointQuarantineText', +export const EDIT_RULE_EXCEPTION_SUCCESS_TITLE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.editException.editRuleExceptionToastSuccessTitle', { - defaultMessage: - 'On all Endpoint hosts, quarantined files that match the exception are automatically restored to their original locations. This exception applies to all rules using Endpoint exceptions.', + defaultMessage: 'Rule exception updated', } ); -export const EXCEPTION_BUILDER_INFO = i18n.translate( - 'xpack.securitySolution.exceptions.editException.infoLabel', - { - defaultMessage: "Alerts are generated when the rule's conditions are met, except when:", - } -); +export const EDIT_RULE_EXCEPTION_SUCCESS_TEXT = (exceptionItemName: string, numItems: number) => + i18n.translate( + 'xpack.securitySolution.ruleExceptions.editException.editRuleExceptionToastSuccessText', + { + values: { exceptionItemName, numItems }, + defaultMessage: + '{numItems, plural, =1 {Exception} other {Exceptions}} - {exceptionItemName} - {numItems, plural, =1 {has} other {have}} been updated.', + } + ); -export const VERSION_CONFLICT_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.editException.versionConflictTitle', +export const EDIT_RULE_EXCEPTION_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.editException.editRuleExceptionToastErrorTitle', { - defaultMessage: 'Sorry, there was an error', + defaultMessage: 'Error updating exception', } ); -export const VERSION_CONFLICT_ERROR_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.exceptions.editException.versionConflictDescription', - { - defaultMessage: - "It appears this exception was updated since you first selected to edit it. Try clicking 'Cancel' and editing the exception again.", - } -); - -export const EDIT_EXCEPTION_SEQUENCE_WARNING = i18n.translate( - 'xpack.securitySolution.exceptions.editException.sequenceWarning', - { - defaultMessage: - "This rule's query contains an EQL sequence statement. The exception modified will apply to all events in the sequence.", - } -); +export const COMMENTS_SECTION_TITLE = (comments: number) => + i18n.translate('xpack.securitySolution.ruleExceptions.editExceptionFlyout.commentsTitle', { + values: { comments }, + defaultMessage: 'Add comments ({comments})', + }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/use_edit_exception.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/use_edit_exception.tsx new file mode 100644 index 000000000000..e5dd0dc4844d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/use_edit_exception.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useState } from 'react'; +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import * as i18n from './translations'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useCreateOrUpdateException } from '../../logic/use_create_update_exception'; + +export interface EditExceptionItemHookProps { + itemsToUpdate: ExceptionListItemSchema[]; +} + +export type EditExceptionItemHookFuncProps = (arg: EditExceptionItemHookProps) => Promise; + +export type ReturnUseEditExceptionItems = [boolean, EditExceptionItemHookFuncProps | null]; + +/** + * Hook for editing exception items from flyout + * + */ +export const useEditExceptionItems = (): ReturnUseEditExceptionItems => { + const { addSuccess, addError, addWarning } = useAppToasts(); + const [isAddingExceptions, updateExceptions] = useCreateOrUpdateException(); + + const [isLoading, setIsLoading] = useState(false); + const updateExceptionsRef = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const updateExceptionItem = async ({ itemsToUpdate }: EditExceptionItemHookProps) => { + if (updateExceptions == null) return; + + try { + setIsLoading(true); + + await updateExceptions(itemsToUpdate); + + addSuccess({ + title: i18n.EDIT_RULE_EXCEPTION_SUCCESS_TITLE, + text: i18n.EDIT_RULE_EXCEPTION_SUCCESS_TEXT( + itemsToUpdate.map(({ name }) => name).join(', '), + itemsToUpdate.length + ), + }); + + if (isSubscribed) { + setIsLoading(false); + } + } catch (e) { + if (isSubscribed) { + setIsLoading(false); + addError(e, { title: i18n.EDIT_RULE_EXCEPTION_ERROR_TITLE }); + throw new Error(e); + } + } + }; + + updateExceptionsRef.current = updateExceptionItem; + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [addSuccess, addError, addWarning, updateExceptions]); + + return [isLoading || isAddingExceptions, updateExceptionsRef.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.test.tsx index f80372dc1128..1697a5d5b0bc 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.test.tsx @@ -8,13 +8,13 @@ import React from 'react'; import { mount } from 'enzyme'; -import { ExceptionItemCard } from '.'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; import { getCommentsArrayMock } from '@kbn/lists-plugin/common/schemas/types/comment.mock'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { TestProviders } from '../../../../common/mock'; import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock'; +import { TestProviders } from '../../../../common/mock'; +import { ExceptionItemCard } from '.'; + jest.mock('../../../../common/lib/kibana'); describe('ExceptionItemCard', () => { @@ -28,8 +28,8 @@ describe('ExceptionItemCard', () => { onDeleteException={jest.fn()} onEditException={jest.fn()} exceptionItem={exceptionItem} - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -77,8 +77,8 @@ describe('ExceptionItemCard', () => { onEditException={jest.fn()} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -125,8 +125,8 @@ describe('ExceptionItemCard', () => { onEditException={jest.fn()} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -169,8 +169,8 @@ describe('ExceptionItemCard', () => { onEditException={mockOnEditException} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -203,6 +203,69 @@ describe('ExceptionItemCard', () => { .find('button[data-test-subj="exceptionItemCardHeader-actionButton"]') .at(0) .simulate('click'); + + expect( + wrapper.find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]').text() + ).toEqual('Edit rule exception'); + + wrapper + .find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]') + .simulate('click'); + + expect(mockOnEditException).toHaveBeenCalledWith(getExceptionListItemSchemaMock()); + }); + + it('it invokes "onEditException" when edit button clicked when "isEndpoint" is "true"', () => { + const mockOnEditException = jest.fn(); + const exceptionItem = getExceptionListItemSchemaMock(); + + const wrapper = mount( + + + + ); + + // click on popover + wrapper + .find('button[data-test-subj="exceptionItemCardHeader-actionButton"]') + .at(0) + .simulate('click'); + + expect( + wrapper.find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]').text() + ).toEqual('Edit endpoint exception'); + wrapper .find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]') .simulate('click'); @@ -222,8 +285,8 @@ describe('ExceptionItemCard', () => { onEditException={jest.fn()} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -256,6 +319,73 @@ describe('ExceptionItemCard', () => { .find('button[data-test-subj="exceptionItemCardHeader-actionButton"]') .at(0) .simulate('click'); + + expect( + wrapper.find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]').text() + ).toEqual('Delete rule exception'); + + wrapper + .find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]') + .simulate('click'); + + expect(mockOnDeleteException).toHaveBeenCalledWith({ + id: '1', + name: 'some name', + namespaceType: 'single', + }); + }); + + it('it invokes "onDeleteException" when delete button clicked when "isEndpoint" is "true"', () => { + const mockOnDeleteException = jest.fn(); + const exceptionItem = getExceptionListItemSchemaMock(); + + const wrapper = mount( + + + + ); + + // click on popover + wrapper + .find('button[data-test-subj="exceptionItemCardHeader-actionButton"]') + .at(0) + .simulate('click'); + + expect( + wrapper.find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]').text() + ).toEqual('Delete endpoint exception'); + wrapper .find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]') .simulate('click'); @@ -278,8 +408,8 @@ describe('ExceptionItemCard', () => { onEditException={jest.fn()} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.tsx index 4bfa09e96486..233e11b0709e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.tsx @@ -9,7 +9,6 @@ import type { EuiCommentProps } from '@elastic/eui'; import { EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useMemo, useCallback } from 'react'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { getFormattedComments } from '../../utils/helpers'; import type { ExceptionListItemIdentifiers } from '../../utils/types'; @@ -22,9 +21,9 @@ import { ExceptionItemCardComments } from './comments'; export interface ExceptionItemProps { exceptionItem: ExceptionListItemSchema; - listType: ExceptionListTypeEnum; + isEndpoint: boolean; disableActions: boolean; - ruleReferences: ExceptionListRuleReferencesSchema | null; + listAndReferences: ExceptionListRuleReferencesSchema | null; onDeleteException: (arg: ExceptionListItemIdentifiers) => void; onEditException: (item: ExceptionListItemSchema) => void; dataTestSubj: string; @@ -33,8 +32,8 @@ export interface ExceptionItemProps { const ExceptionItemCardComponent = ({ disableActions, exceptionItem, - listType, - ruleReferences, + isEndpoint, + listAndReferences, onDeleteException, onEditException, dataTestSubj, @@ -65,19 +64,17 @@ const ExceptionItemCardComponent = ({ { key: 'edit', icon: 'controlsHorizontal', - label: - listType === ExceptionListTypeEnum.ENDPOINT - ? i18n.ENDPOINT_EXCEPTION_ITEM_EDIT_BUTTON - : i18n.EXCEPTION_ITEM_EDIT_BUTTON, + label: isEndpoint + ? i18n.ENDPOINT_EXCEPTION_ITEM_EDIT_BUTTON + : i18n.EXCEPTION_ITEM_EDIT_BUTTON, onClick: handleEdit, }, { key: 'delete', icon: 'trash', - label: - listType === ExceptionListTypeEnum.ENDPOINT - ? i18n.ENDPOINT_EXCEPTION_ITEM_DELETE_BUTTON - : i18n.EXCEPTION_ITEM_DELETE_BUTTON, + label: isEndpoint + ? i18n.ENDPOINT_EXCEPTION_ITEM_DELETE_BUTTON + : i18n.EXCEPTION_ITEM_DELETE_BUTTON, onClick: handleDelete, }, ]} @@ -88,7 +85,7 @@ const ExceptionItemCardComponent = ({ diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.test.tsx index 8892117f1616..0a355a4567a8 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.test.tsx @@ -6,193 +6,233 @@ */ import React from 'react'; +import type { ReactWrapper } from 'enzyme'; import { mount } from 'enzyme'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { ExceptionItemCardMetaInfo } from './meta'; import { TestProviders } from '../../../../common/mock'; describe('ExceptionItemCardMetaInfo', () => { - it('it renders item creation info', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value1"]').at(0).text() - ).toEqual('Apr 20, 2020 @ 15:25:31.830'); - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value2"]').at(0).text() - ).toEqual('some user'); - }); + describe('general functionality', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount( + + + + ); + }); + + it('it renders item creation info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value1"]').at(0).text() + ).toEqual('Apr 20, 2020 @ 15:25:31.830'); + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value2"]').at(0).text() + ).toEqual('some user'); + }); + + it('it renders item update info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value1"]').at(0).text() + ).toEqual('Apr 20, 2020 @ 15:25:31.830'); + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value2"]').at(0).text() + ).toEqual('some user'); + }); + + it('it renders references info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() + ).toEqual('Affects 1 rule'); + }); + + it('it renders affected shared list info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedListsButton"]').at(0).text() + ).toEqual('Affects shared list'); + }); + + it('it renders references info when multiple references exist', () => { + wrapper = mount( + + + + ); - it('it renders item update info', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value1"]').at(0).text() - ).toEqual('Apr 20, 2020 @ 15:25:31.830'); - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value2"]').at(0).text() - ).toEqual('some user'); + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() + ).toEqual('Affects 2 rules'); + }); }); - it('it renders references info', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() - ).toEqual('Affects 1 rule'); + describe('exception item for "rule_default" list', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount( + + + + ); + }); + + it('it renders references info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() + ).toEqual('Affects 1 rule'); + }); + + it('it does NOT render affected shared list info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedListsButton"]').exists() + ).toBeFalsy(); + }); }); - it('it renders references info when multiple references exist', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() - ).toEqual('Affects 2 rules'); + describe('exception item for "endpoint" list', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount( + + + + ); + }); + + it('it renders references info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() + ).toEqual('Affects 1 rule'); + }); + + it('it renders affected shared list info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedListsButton"]').at(0).text() + ).toEqual('Affects shared list'); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx index 453e1542bfce..8005264636bf 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx @@ -19,6 +19,7 @@ import { EuiPopover, } from '@elastic/eui'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import styled from 'styled-components'; import * as i18n from './translations'; @@ -36,24 +37,27 @@ const StyledFlexItem = styled(EuiFlexItem)` export interface ExceptionItemCardMetaInfoProps { item: ExceptionListItemSchema; - references: ExceptionListRuleReferencesSchema | null; + listAndReferences: ExceptionListRuleReferencesSchema | null; dataTestSubj: string; } export const ExceptionItemCardMetaInfo = memo( - ({ item, references, dataTestSubj }) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); + ({ item, listAndReferences, dataTestSubj }) => { + const [isListsPopoverOpen, setIsListsPopoverOpen] = useState(false); + const [isRulesPopoverOpen, setIsRulesPopoverOpen] = useState(false); - const onAffectedRulesClick = () => setIsPopoverOpen((isOpen) => !isOpen); - const onClosePopover = () => setIsPopoverOpen(false); + const onAffectedRulesClick = () => setIsRulesPopoverOpen((isOpen) => !isOpen); + const onAffectedListsClick = () => setIsListsPopoverOpen((isOpen) => !isOpen); + const onCloseRulesPopover = () => setIsRulesPopoverOpen(false); + const onClosListsPopover = () => setIsListsPopoverOpen(false); const itemActions = useMemo((): EuiContextMenuPanelProps['items'] => { - if (references == null) { + if (listAndReferences == null) { return []; } - return references.referenced_rules.map((reference) => ( + return listAndReferences.referenced_rules.map((reference) => ( @@ -61,13 +65,92 @@ export const ExceptionItemCardMetaInfo = memo( data-test-subj="ruleName" deepLinkId={SecurityPageName.rules} path={getRuleDetailsTabUrl(reference.id, RuleDetailTabs.alerts)} + external > {reference.name} )); - }, [references, dataTestSubj]); + }, [listAndReferences, dataTestSubj]); + + const rulesAffected = useMemo((): JSX.Element => { + if (listAndReferences == null) return <>; + + return ( + + + {i18n.AFFECTED_RULES(listAndReferences?.referenced_rules.length ?? 0)} + + } + panelPaddingSize="none" + isOpen={isRulesPopoverOpen} + closePopover={onCloseRulesPopover} + data-test-subj={`${dataTestSubj}-rulesPopover`} + id={'rulesPopover'} + > + + + + ); + }, [listAndReferences, dataTestSubj, isRulesPopoverOpen, itemActions]); + + const listsAffected = useMemo((): JSX.Element => { + if (listAndReferences == null) return <>; + + if (listAndReferences.type !== ExceptionListTypeEnum.RULE_DEFAULT) { + return ( + + + {i18n.AFFECTED_LIST} + + } + panelPaddingSize="none" + isOpen={isListsPopoverOpen} + closePopover={onClosListsPopover} + data-test-subj={`${dataTestSubj}-listsPopover`} + id={'listsPopover'} + > + + + + {listAndReferences.name} + + + , + ]} + /> + + + ); + } else { + return <>; + } + }, [listAndReferences, dataTestSubj, isListsPopoverOpen]); return ( ( dataTestSubj={`${dataTestSubj}-updatedBy`} /> - {references != null && ( - - - {i18n.AFFECTED_RULES(references?.referenced_rules.length ?? 0)} - - } - panelPaddingSize="none" - isOpen={isPopoverOpen} - closePopover={onClosePopover} - data-test-subj={`${dataTestSubj}-items`} - > - - - + {listAndReferences != null && ( + <> + {rulesAffected} + {listsAffected} + )} ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts index ccdd5eebf3b8..75d0b098c297 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts @@ -8,174 +8,181 @@ import { i18n } from '@kbn/i18n'; export const EXCEPTION_ITEM_EDIT_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.editItemButton', + 'xpack.securitySolution.ruleExceptions.exceptionItem.editItemButton', { defaultMessage: 'Edit rule exception', } ); export const EXCEPTION_ITEM_DELETE_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.deleteItemButton', + 'xpack.securitySolution.ruleExceptions.exceptionItem.deleteItemButton', { defaultMessage: 'Delete rule exception', } ); export const ENDPOINT_EXCEPTION_ITEM_EDIT_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.endpoint.editItemButton', + 'xpack.securitySolution.ruleExceptions.exceptionItem.endpoint.editItemButton', { defaultMessage: 'Edit endpoint exception', } ); export const ENDPOINT_EXCEPTION_ITEM_DELETE_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.endpoint.deleteItemButton', + 'xpack.securitySolution.ruleExceptions.exceptionItem.endpoint.deleteItemButton', { defaultMessage: 'Delete endpoint exception', } ); export const EXCEPTION_ITEM_CREATED_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.createdLabel', + 'xpack.securitySolution.ruleExceptions.exceptionItem.createdLabel', { defaultMessage: 'Created', } ); export const EXCEPTION_ITEM_UPDATED_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.updatedLabel', + 'xpack.securitySolution.ruleExceptions.exceptionItem.updatedLabel', { defaultMessage: 'Updated', } ); export const EXCEPTION_ITEM_META_BY = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy', + 'xpack.securitySolution.ruleExceptions.exceptionItem.metaDetailsBy', { defaultMessage: 'by', } ); export const exceptionItemCommentsAccordion = (comments: number) => - i18n.translate('xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel', { + i18n.translate('xpack.securitySolution.ruleExceptions.exceptionItem.showCommentsLabel', { values: { comments }, defaultMessage: 'Show {comments, plural, =1 {comment} other {comments}} ({comments})', }); export const CONDITION_OPERATOR_TYPE_MATCH = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.matchOperator', { defaultMessage: 'IS', } ); export const CONDITION_OPERATOR_TYPE_NOT_MATCH = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.matchOperator.not', { defaultMessage: 'IS NOT', } ); export const CONDITION_OPERATOR_TYPE_WILDCARD_MATCHES = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.wildcardMatchesOperator', { defaultMessage: 'MATCHES', } ); export const CONDITION_OPERATOR_TYPE_WILDCARD_DOES_NOT_MATCH = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator', { defaultMessage: 'DOES NOT MATCH', } ); export const CONDITION_OPERATOR_TYPE_NESTED = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.nestedOperator', { defaultMessage: 'has', } ); export const CONDITION_OPERATOR_TYPE_MATCH_ANY = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.matchAnyOperator', { defaultMessage: 'is one of', } ); export const CONDITION_OPERATOR_TYPE_NOT_MATCH_ANY = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.matchAnyOperator.not', { defaultMessage: 'is not one of', } ); export const CONDITION_OPERATOR_TYPE_EXISTS = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.existsOperator', { defaultMessage: 'exists', } ); export const CONDITION_OPERATOR_TYPE_DOES_NOT_EXIST = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator.not', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.existsOperator.not', { defaultMessage: 'does not exist', } ); export const CONDITION_OPERATOR_TYPE_LIST = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.listOperator', { defaultMessage: 'included in', } ); export const CONDITION_OPERATOR_TYPE_NOT_IN_LIST = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator.not', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.listOperator.not', { defaultMessage: 'is not included in', } ); export const CONDITION_AND = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.and', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.and', { defaultMessage: 'AND', } ); export const CONDITION_OS = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.os', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.os', { defaultMessage: 'OS', } ); export const OS_WINDOWS = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.windows', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.windows', { defaultMessage: 'Windows', } ); export const OS_LINUX = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.linux', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.linux', { defaultMessage: 'Linux', } ); export const OS_MAC = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.macos', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.macos', { defaultMessage: 'Mac', } ); export const AFFECTED_RULES = (numRules: number) => - i18n.translate('xpack.securitySolution.exceptions.exceptionItem.affectedRules', { + i18n.translate('xpack.securitySolution.ruleExceptions.exceptionItem.affectedRules', { values: { numRules }, defaultMessage: 'Affects {numRules} {numRules, plural, =1 {rule} other {rules}}', }); + +export const AFFECTED_LIST = i18n.translate( + 'xpack.securitySolution.ruleExceptions.exceptionItem.affectedList', + { + defaultMessage: 'Affects shared list', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/item_conditions/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/item_conditions/index.tsx index 46f16ec46887..4869c80c172a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/item_conditions/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/item_conditions/index.tsx @@ -266,6 +266,7 @@ const ExceptionsConditionsComponent: React.FC ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx index 214d0041fb9a..47933db0b352 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx @@ -110,7 +110,6 @@ describe('ExceptionItemComments', () => { ); - expect(wrapper.find('[data-test-subj="exceptionItemCommentsAccordion"]').exists()).toBeFalsy(); expect( wrapper.find('[data-test-subj="newExceptionItemCommentTextArea"]').at(1).props().value ).toEqual('This is a new comment'); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx index fa514850e30b..a3553c78f8b3 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx @@ -82,10 +82,6 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({ setShouldShowComments(isOpen); }, []); - const exceptionItemsExist: boolean = useMemo(() => { - return exceptionItemComments != null && exceptionItemComments.length > 0; - }, [exceptionItemComments]); - const commentsAccordionTitle = useMemo(() => { if (exceptionItemComments && exceptionItemComments.length > 0) { return ( @@ -110,32 +106,30 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({ return (
- {exceptionItemsExist && ( - handleTriggerOnClick(isOpen)} - > - - - )} - - - - - - - - + handleTriggerOnClick(isOpen)} + > + + + + + + + + + +
); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/translations.ts new file mode 100644 index 000000000000..8c1ceb9f639f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const CLOSE_ALERTS_SUCCESS = (numAlerts: number) => + i18n.translate('xpack.securitySolution.ruleExceptions.logic.closeAlerts.success', { + values: { numAlerts }, + defaultMessage: + 'Successfully updated {numAlerts} {numAlerts, plural, =1 {alert} other {alerts}}', + }); + +export const CLOSE_ALERTS_ERROR = i18n.translate( + 'xpack.securitySolution.ruleExceptions.logic.closeAlerts.error', + { + defaultMessage: 'Failed to close alerts', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.test.tsx deleted file mode 100644 index e882e05f6e08..000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.test.tsx +++ /dev/null @@ -1,423 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { act, renderHook } from '@testing-library/react-hooks'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { coreMock } from '@kbn/core/public/mocks'; -import { KibanaServices } from '../../../common/lib/kibana'; - -import * as alertsApi from '../../../detections/containers/detection_engine/alerts/api'; -import * as listsApi from '@kbn/securitysolution-list-api'; -import * as getQueryFilterHelper from '../../../detections/containers/detection_engine/exceptions/get_es_query_filter'; -import * as buildFilterHelpers from '../../../detections/components/alerts_table/default_config'; -import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import { getCreateExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_item_schema.mock'; -import { getUpdateExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/request/update_exception_list_item_schema.mock'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, - UpdateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { TestProviders } from '../../../common/mock'; -import type { - UseAddOrUpdateExceptionProps, - ReturnUseAddOrUpdateException, - AddOrUpdateExceptionItemsFunc, -} from './use_add_exception'; -import { useAddOrUpdateException } from './use_add_exception'; - -const mockKibanaHttpService = coreMock.createStart().http; -const mockKibanaServices = KibanaServices.get as jest.Mock; -jest.mock('../../../common/lib/kibana'); -jest.mock('@kbn/securitysolution-list-api'); - -const fetchMock = jest.fn(); -mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); - -describe('useAddOrUpdateException', () => { - let updateAlertStatus: jest.SpyInstance>; - let addExceptionListItem: jest.SpyInstance>; - let updateExceptionListItem: jest.SpyInstance>; - let getQueryFilter: jest.SpyInstance>; - let buildAlertStatusesFilter: jest.SpyInstance< - ReturnType - >; - let buildAlertsFilter: jest.SpyInstance>; - let addOrUpdateItemsArgs: Parameters; - let render: () => RenderHookResult; - const onError = jest.fn(); - const onSuccess = jest.fn(); - const ruleStaticId = 'rule-id'; - const alertIdToClose = 'idToClose'; - const bulkCloseIndex = ['.custom']; - const itemsToAdd: CreateExceptionListItemSchema[] = [ - { - ...getCreateExceptionListItemSchemaMock(), - name: 'item to add 1', - }, - { - ...getCreateExceptionListItemSchemaMock(), - name: 'item to add 2', - }, - ]; - const itemsToUpdate: ExceptionListItemSchema[] = [ - { - ...getExceptionListItemSchemaMock(), - name: 'item to update 1', - }, - { - ...getExceptionListItemSchemaMock(), - name: 'item to update 2', - }, - ]; - const itemsToUpdateFormatted: UpdateExceptionListItemSchema[] = itemsToUpdate.map( - (item: ExceptionListItemSchema) => { - const formatted: UpdateExceptionListItemSchema = getUpdateExceptionListItemSchemaMock(); - const newObj = (Object.keys(formatted) as Array).reduce( - (acc, key) => { - return { - ...acc, - [key]: item[key], - }; - }, - {} as UpdateExceptionListItemSchema - ); - return newObj; - } - ); - - const itemsToAddOrUpdate = [...itemsToAdd, ...itemsToUpdate]; - - const waitForAddOrUpdateFunc: (arg: { - waitForNextUpdate: RenderHookResult< - UseAddOrUpdateExceptionProps, - ReturnUseAddOrUpdateException - >['waitForNextUpdate']; - rerender: RenderHookResult< - UseAddOrUpdateExceptionProps, - ReturnUseAddOrUpdateException - >['rerender']; - result: RenderHookResult['result']; - }) => Promise = async ({ - waitForNextUpdate, - rerender, - result, - }) => { - await waitForNextUpdate(); - rerender(); - expect(result.current[1]).not.toBeNull(); - return Promise.resolve(result.current[1]); - }; - - beforeEach(() => { - updateAlertStatus = jest.spyOn(alertsApi, 'updateAlertStatus'); - - addExceptionListItem = jest - .spyOn(listsApi, 'addExceptionListItem') - .mockResolvedValue(getExceptionListItemSchemaMock()); - - updateExceptionListItem = jest - .spyOn(listsApi, 'updateExceptionListItem') - .mockResolvedValue(getExceptionListItemSchemaMock()); - - getQueryFilter = jest - .spyOn(getQueryFilterHelper, 'getEsQueryFilter') - .mockResolvedValue({ bool: { must_not: [], must: [], filter: [], should: [] } }); - - buildAlertStatusesFilter = jest.spyOn(buildFilterHelpers, 'buildAlertStatusesFilter'); - - buildAlertsFilter = jest.spyOn(buildFilterHelpers, 'buildAlertsFilter'); - - addOrUpdateItemsArgs = [ruleStaticId, itemsToAddOrUpdate]; - render = () => - renderHook( - () => - useAddOrUpdateException({ - http: mockKibanaHttpService, - onError, - onSuccess, - }), - { - wrapper: TestProviders, - } - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - it('initializes hook', async () => { - await act(async () => { - const { result, waitForNextUpdate } = render(); - await waitForNextUpdate(); - expect(result.current).toEqual([{ isLoading: false }, result.current[1]]); - }); - }); - - it('invokes "onError" if call to add exception item fails', async () => { - const mockError = new Error('error adding item'); - - addExceptionListItem = jest - .spyOn(listsApi, 'addExceptionListItem') - .mockRejectedValue(mockError); - - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(onError).toHaveBeenCalledWith(mockError, null, null); - }); - }); - - it('invokes "onError" if call to update exception item fails', async () => { - const mockError = new Error('error updating item'); - - updateExceptionListItem = jest - .spyOn(listsApi, 'updateExceptionListItem') - .mockRejectedValue(mockError); - - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(onError).toHaveBeenCalledWith(mockError, null, null); - }); - }); - - describe('when alertIdToClose is not passed in', () => { - it('should not update the alert status', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateAlertStatus).not.toHaveBeenCalled(); - }); - }); - - it('creates new items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(addExceptionListItem).toHaveBeenCalledTimes(2); - expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); - }); - }); - it('updates existing items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateExceptionListItem).toHaveBeenCalledTimes(2); - expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( - itemsToUpdateFormatted[1] - ); - }); - }); - }); - - describe('when alertIdToClose is passed in', () => { - beforeEach(() => { - addOrUpdateItemsArgs = [ruleStaticId, itemsToAddOrUpdate, alertIdToClose]; - }); - it('should update the alert status', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateAlertStatus).toHaveBeenCalledTimes(1); - }); - }); - it('creates new items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(addExceptionListItem).toHaveBeenCalledTimes(2); - expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); - }); - }); - it('updates existing items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateExceptionListItem).toHaveBeenCalledTimes(2); - expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( - itemsToUpdateFormatted[1] - ); - }); - }); - }); - - describe('when bulkCloseIndex is passed in', () => { - beforeEach(() => { - addOrUpdateItemsArgs = [ruleStaticId, itemsToAddOrUpdate, undefined, bulkCloseIndex]; - }); - it('should update the status of only alerts that are open', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(buildAlertStatusesFilter).toHaveBeenCalledTimes(1); - expect(buildAlertStatusesFilter.mock.calls[0][0]).toEqual([ - 'open', - 'acknowledged', - 'in-progress', - ]); - }); - }); - it('should update the status of only alerts generated by the provided rule', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(buildAlertsFilter).toHaveBeenCalledTimes(1); - expect(buildAlertsFilter.mock.calls[0][0]).toEqual(ruleStaticId); - }); - }); - it('should generate the query filter using exceptions', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(getQueryFilter).toHaveBeenCalledTimes(1); - expect(getQueryFilter.mock.calls[0][4]).toEqual(itemsToAddOrUpdate); - expect(getQueryFilter.mock.calls[0][5]).toEqual(false); - }); - }); - it('should update the alert status', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateAlertStatus).toHaveBeenCalledTimes(1); - }); - }); - it('creates new items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(addExceptionListItem).toHaveBeenCalledTimes(2); - expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); - }); - }); - it('updates existing items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateExceptionListItem).toHaveBeenCalledTimes(2); - expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( - itemsToUpdateFormatted[1] - ); - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.tsx deleted file mode 100644 index a6149f366dfa..000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useEffect, useRef, useState, useCallback } from 'react'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { useApi, removeIdFromExceptionItemsEntries } from '@kbn/securitysolution-list-hooks'; -import type { HttpStart } from '@kbn/core/public'; - -import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api'; -import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions'; -import { - buildAlertsFilter, - buildAlertStatusesFilter, -} from '../../../detections/components/alerts_table/default_config'; -import { getEsQueryFilter } from '../../../detections/containers/detection_engine/exceptions/get_es_query_filter'; -import type { Index } from '../../../../common/detection_engine/schemas/common/schemas'; -import { formatExceptionItemForUpdate, prepareExceptionItemsForBulkClose } from '../utils/helpers'; -import { useKibana } from '../../../common/lib/kibana'; - -/** - * Adds exception items to the list. Also optionally closes alerts. - * - * @param ruleStaticId static id of the rule (rule.ruleId, not rule.id) where the exception updates will be applied - * @param exceptionItemsToAddOrUpdate array of ExceptionListItemSchema to add or update - * @param alertIdToClose - optional string representing alert to close - * @param bulkCloseIndex - optional index used to create bulk close query - * - */ -export type AddOrUpdateExceptionItemsFunc = ( - ruleStaticId: string, - exceptionItemsToAddOrUpdate: Array, - alertIdToClose?: string, - bulkCloseIndex?: Index -) => Promise; - -export type ReturnUseAddOrUpdateException = [ - { isLoading: boolean }, - AddOrUpdateExceptionItemsFunc | null -]; - -export interface UseAddOrUpdateExceptionProps { - http: HttpStart; - onError: (arg: Error, code: number | null, message: string | null) => void; - onSuccess: (updated: number, conficts: number) => void; -} - -/** - * Hook for adding and updating an exception item - * - * @param http Kibana http service - * @param onError error callback - * @param onSuccess callback when all lists fetched successfully - * - */ -export const useAddOrUpdateException = ({ - http, - onError, - onSuccess, -}: UseAddOrUpdateExceptionProps): ReturnUseAddOrUpdateException => { - const { services } = useKibana(); - const [isLoading, setIsLoading] = useState(false); - const addOrUpdateExceptionRef = useRef(null); - const { addExceptionListItem, updateExceptionListItem } = useApi(services.http); - const addOrUpdateException = useCallback( - async (ruleStaticId, exceptionItemsToAddOrUpdate, alertIdToClose, bulkCloseIndex) => { - if (addOrUpdateExceptionRef.current != null) { - addOrUpdateExceptionRef.current( - ruleStaticId, - exceptionItemsToAddOrUpdate, - alertIdToClose, - bulkCloseIndex - ); - } - }, - [] - ); - - useEffect(() => { - let isSubscribed = true; - const abortCtrl = new AbortController(); - - const onUpdateExceptionItemsAndAlertStatus: AddOrUpdateExceptionItemsFunc = async ( - ruleStaticId, - exceptionItemsToAddOrUpdate, - alertIdToClose, - bulkCloseIndex - ) => { - const addOrUpdateItems = async ( - exceptionListItems: Array - ): Promise => { - await Promise.all( - exceptionListItems.map( - (item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { - if ('id' in item && item.id != null) { - const formattedExceptionItem = formatExceptionItemForUpdate(item); - return updateExceptionListItem({ - listItem: formattedExceptionItem, - }); - } else { - return addExceptionListItem({ - listItem: item, - }); - } - } - ) - ); - }; - - try { - setIsLoading(true); - let alertIdResponse: estypes.UpdateByQueryResponse | undefined; - let bulkResponse: estypes.UpdateByQueryResponse | undefined; - if (alertIdToClose != null) { - alertIdResponse = await updateAlertStatus({ - query: getUpdateAlertsQuery([alertIdToClose]), - status: 'closed', - signal: abortCtrl.signal, - }); - } - - if (bulkCloseIndex != null) { - const alertStatusFilter = buildAlertStatusesFilter([ - 'open', - 'acknowledged', - 'in-progress', - ]); - - const exceptionsToFilter = exceptionItemsToAddOrUpdate.map((exception) => - removeIdFromExceptionItemsEntries(exception) - ); - - const filter = await getEsQueryFilter( - '', - 'kuery', - [...buildAlertsFilter(ruleStaticId), ...alertStatusFilter], - bulkCloseIndex, - prepareExceptionItemsForBulkClose(exceptionsToFilter), - false - ); - - bulkResponse = await updateAlertStatus({ - query: { - query: filter, - }, - status: 'closed', - signal: abortCtrl.signal, - }); - } - - await addOrUpdateItems(exceptionItemsToAddOrUpdate); - - // NOTE: there could be some overlap here... it's possible that the first response had conflicts - // but that the alert was closed in the second call. In this case, a conflict will be reported even - // though it was already resolved. I'm not sure that there's an easy way to solve this, but it should - // have minimal impact on the user... they'd see a warning that indicates a possible conflict, but the - // state of the alerts and their representation in the UI would be consistent. - const updated = (alertIdResponse?.updated ?? 0) + (bulkResponse?.updated ?? 0); - const conflicts = - alertIdResponse?.version_conflicts ?? 0 + (bulkResponse?.version_conflicts ?? 0); - if (isSubscribed) { - setIsLoading(false); - onSuccess(updated, conflicts); - } - } catch (error) { - if (isSubscribed) { - setIsLoading(false); - if (error.body != null) { - onError(error, error.body.status_code, error.body.message); - } else { - onError(error, null, null); - } - } - } - }; - - addOrUpdateExceptionRef.current = onUpdateExceptionItemsAndAlertStatus; - return (): void => { - isSubscribed = false; - abortCtrl.abort(); - }; - }, [addExceptionListItem, http, onSuccess, onError, updateExceptionListItem]); - - return [{ isLoading }, addOrUpdateException]; -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_rule_exception.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_rule_exception.tsx new file mode 100644 index 000000000000..7cf4ff2d8417 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_rule_exception.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + CreateRuleExceptionListItemSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { useEffect, useRef, useState } from 'react'; + +import { addRuleExceptions } from '../../../detections/containers/detection_engine/rules/api'; +import type { Rule } from '../../../detections/containers/detection_engine/rules/types'; + +/** + * Adds exception items to rules default exception list + * + * @param exceptions exception items to be added + * @param ruleId `id` of rule to add exceptions to + * + */ +export type AddRuleExceptionsFunc = ( + exceptions: CreateRuleExceptionListItemSchema[], + rules: Rule[] +) => Promise; + +export type ReturnUseAddRuleException = [boolean, AddRuleExceptionsFunc | null]; + +/** + * Hook for adding exceptions to a rule default exception list + * + */ +export const useAddRuleDefaultException = (): ReturnUseAddRuleException => { + const [isLoading, setIsLoading] = useState(false); + const addRuleExceptionFunc = useRef(null); + + useEffect(() => { + const abortCtrl = new AbortController(); + + const addExceptionItemsToRule: AddRuleExceptionsFunc = async ( + exceptions: CreateRuleExceptionListItemSchema[], + rules: Rule[] + ): Promise => { + setIsLoading(true); + + // TODO: Update once bulk route is added + const result = await Promise.all( + rules.map(async (rule) => + addRuleExceptions({ + items: exceptions, + ruleId: rule.id, + signal: abortCtrl.signal, + }) + ) + ); + + setIsLoading(false); + + return result.flatMap((r) => r); + }; + addRuleExceptionFunc.current = addExceptionItemsToRule; + + return (): void => { + setIsLoading(false); + abortCtrl.abort(); + }; + }, []); + + return [isLoading, addRuleExceptionFunc.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_close_alerts.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_close_alerts.tsx new file mode 100644 index 000000000000..5dc960cb96e2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_close_alerts.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useState } from 'react'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api'; +import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions'; +import { + buildAlertStatusesFilter, + buildAlertsFilter, +} from '../../../detections/components/alerts_table/default_config'; +import { getEsQueryFilter } from '../../../detections/containers/detection_engine/exceptions/get_es_query_filter'; +import type { Index } from '../../../../common/detection_engine/schemas/common/schemas'; +import { prepareExceptionItemsForBulkClose } from '../utils/helpers'; +import * as i18nCommon from '../../../common/translations'; +import * as i18n from './translations'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; + +/** + * Closes alerts. + * + * @param ruleStaticIds static id of the rules (rule.ruleId, not rule.id) where the exception updates will be applied + * @param exceptionItems array of ExceptionListItemSchema to add or update + * @param alertIdToClose - optional string representing alert to close + * @param bulkCloseIndex - optional index used to create bulk close query + * + */ +export type AddOrUpdateExceptionItemsFunc = ( + ruleStaticIds: string[], + exceptionItems: ExceptionListItemSchema[], + alertIdToClose?: string, + bulkCloseIndex?: Index +) => Promise; + +export type ReturnUseCloseAlertsFromExceptions = [boolean, AddOrUpdateExceptionItemsFunc | null]; + +/** + * Hook for closing alerts from exceptions + */ +export const useCloseAlertsFromExceptions = (): ReturnUseCloseAlertsFromExceptions => { + const { addSuccess, addError, addWarning } = useAppToasts(); + + const [isLoading, setIsLoading] = useState(false); + const closeAlertsRef = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const onUpdateAlerts: AddOrUpdateExceptionItemsFunc = async ( + ruleStaticIds, + exceptionItems, + alertIdToClose, + bulkCloseIndex + ) => { + try { + setIsLoading(true); + let alertIdResponse: estypes.UpdateByQueryResponse | undefined; + let bulkResponse: estypes.UpdateByQueryResponse | undefined; + if (alertIdToClose != null) { + alertIdResponse = await updateAlertStatus({ + query: getUpdateAlertsQuery([alertIdToClose]), + status: 'closed', + signal: abortCtrl.signal, + }); + } + + if (bulkCloseIndex != null) { + const alertStatusFilter = buildAlertStatusesFilter([ + 'open', + 'acknowledged', + 'in-progress', + ]); + + const filter = await getEsQueryFilter( + '', + 'kuery', + [...ruleStaticIds.flatMap((id) => buildAlertsFilter(id)), ...alertStatusFilter], + bulkCloseIndex, + prepareExceptionItemsForBulkClose(exceptionItems), + false + ); + + bulkResponse = await updateAlertStatus({ + query: { + query: filter, + }, + status: 'closed', + signal: abortCtrl.signal, + }); + } + + // NOTE: there could be some overlap here... it's possible that the first response had conflicts + // but that the alert was closed in the second call. In this case, a conflict will be reported even + // though it was already resolved. I'm not sure that there's an easy way to solve this, but it should + // have minimal impact on the user... they'd see a warning that indicates a possible conflict, but the + // state of the alerts and their representation in the UI would be consistent. + const updated = (alertIdResponse?.updated ?? 0) + (bulkResponse?.updated ?? 0); + const conflicts = + alertIdResponse?.version_conflicts ?? 0 + (bulkResponse?.version_conflicts ?? 0); + if (isSubscribed) { + setIsLoading(false); + addSuccess(i18n.CLOSE_ALERTS_SUCCESS(updated)); + if (conflicts > 0) { + addWarning({ + title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } + } + } catch (error) { + if (isSubscribed) { + setIsLoading(false); + addError(error, { title: i18n.CLOSE_ALERTS_ERROR }); + } + } + }; + + closeAlertsRef.current = onUpdateAlerts; + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [addSuccess, addError, addWarning]); + + return [isLoading, closeAlertsRef.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_create_update_exception.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_create_update_exception.tsx new file mode 100644 index 000000000000..fbe9c0d46e6b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_create_update_exception.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useState } from 'react'; +import type { + CreateExceptionListItemSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { useApi } from '@kbn/securitysolution-list-hooks'; + +import { formatExceptionItemForUpdate } from '../utils/helpers'; +import { useKibana } from '../../../common/lib/kibana'; + +export type CreateOrUpdateExceptionItemsFunc = ( + args: Array +) => Promise; + +export type ReturnUseCreateOrUpdateException = [boolean, CreateOrUpdateExceptionItemsFunc | null]; + +/** + * Hook for adding and/or updating an exception item + */ +export const useCreateOrUpdateException = (): ReturnUseCreateOrUpdateException => { + const { + services: { http }, + } = useKibana(); + const [isLoading, setIsLoading] = useState(false); + const addOrUpdateExceptionRef = useRef(null); + const { addExceptionListItem, updateExceptionListItem } = useApi(http); + + useEffect(() => { + const abortCtrl = new AbortController(); + + const onCreateOrUpdateExceptionItem: CreateOrUpdateExceptionItemsFunc = async (items) => { + setIsLoading(true); + const itemsAdded = await Promise.all( + items.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { + if ('id' in item && item.id != null) { + const formattedExceptionItem = formatExceptionItemForUpdate(item); + return updateExceptionListItem({ + listItem: formattedExceptionItem, + }); + } else { + return addExceptionListItem({ + listItem: item, + }); + } + }) + ); + + setIsLoading(false); + + return itemsAdded; + }; + + addOrUpdateExceptionRef.current = onCreateOrUpdateExceptionItem; + return (): void => { + setIsLoading(false); + abortCtrl.abort(); + }; + }, [updateExceptionListItem, http, addExceptionListItem]); + + return [isLoading, addOrUpdateExceptionRef.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx new file mode 100644 index 000000000000..57cfda994d37 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useState, useMemo } from 'react'; +import type { DataViewBase } from '@kbn/es-query'; + +import type { Rule } from '../../../detections/containers/detection_engine/rules/types'; +import { useGetInstalledJob } from '../../../common/components/ml/hooks/use_get_jobs'; +import { useKibana } from '../../../common/lib/kibana'; +import { useFetchIndex } from '../../../common/containers/source'; + +export interface ReturnUseFetchExceptionFlyoutData { + isLoading: boolean; + indexPatterns: DataViewBase; +} + +/** + * Hook for fetching the fields to be used for populating the exception + * item conditions options. + * + */ +export const useFetchIndexPatterns = (rules: Rule[] | null): ReturnUseFetchExceptionFlyoutData => { + const { data } = useKibana().services; + const [dataViewLoading, setDataViewLoading] = useState(false); + const isSingleRule = useMemo(() => rules != null && rules.length === 1, [rules]); + const isMLRule = useMemo( + () => rules != null && isSingleRule && rules[0].type === 'machine_learning', + [isSingleRule, rules] + ); + // If data view is defined, it superceeds use of rule defined index patterns. + // If no rule is available, use fields from default data view id. + const memoDataViewId = useMemo( + () => + rules != null && isSingleRule ? rules[0].data_view_id || null : 'security-solution-default', + [isSingleRule, rules] + ); + + const memoNonDataViewIndexPatterns = useMemo( + () => + !memoDataViewId && rules != null && isSingleRule && rules[0].index != null + ? rules[0].index + : [], + [memoDataViewId, isSingleRule, rules] + ); + + // Index pattern logic for ML + const memoMlJobIds = useMemo( + () => (isMLRule && isSingleRule && rules != null ? rules[0].machine_learning_job_id ?? [] : []), + [isMLRule, isSingleRule, rules] + ); + const { loading: mlJobLoading, jobs } = useGetInstalledJob(memoMlJobIds); + + // We only want to provide a non empty array if it's an ML rule and we were able to fetch + // the index patterns, or if it's a rule not using data views. Otherwise, return an empty + // empty array to avoid making the `useFetchIndex` call + const memoRuleIndices = useMemo(() => { + if (isMLRule && jobs.length > 0) { + return jobs[0].results_index_name ? [`.ml-anomalies-${jobs[0].results_index_name}`] : []; + } else if (memoDataViewId != null) { + return []; + } else { + return memoNonDataViewIndexPatterns; + } + }, [jobs, isMLRule, memoDataViewId, memoNonDataViewIndexPatterns]); + + const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] = + useFetchIndex(memoRuleIndices); + + // Data view logic + const [dataViewIndexPatterns, setDataViewIndexPatterns] = useState(null); + useEffect(() => { + const fetchSingleDataView = async () => { + if (memoDataViewId) { + setDataViewLoading(true); + const dv = await data.dataViews.get(memoDataViewId); + setDataViewLoading(false); + setDataViewIndexPatterns(dv); + } + }; + + fetchSingleDataView(); + }, [memoDataViewId, data.dataViews, setDataViewIndexPatterns]); + + // Determine whether to use index patterns or data views + const indexPatternsToUse = useMemo( + (): DataViewBase => + memoDataViewId && dataViewIndexPatterns != null ? dataViewIndexPatterns : indexIndexPatterns, + [memoDataViewId, dataViewIndexPatterns, indexIndexPatterns] + ); + + return { + isLoading: isIndexPatternLoading || mlJobLoading || dataViewLoading, + indexPatterns: indexPatternsToUse, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx index c88a7d16b6c4..a9a43ef2e47a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx @@ -16,8 +16,6 @@ import { enrichNewExceptionItemsWithComments, enrichExistingExceptionItemWithComments, enrichExceptionItemsWithOS, - entryHasListType, - entryHasNonEcsType, prepareExceptionItemsForBulkClose, lowercaseHashValues, getPrepopulatedEndpointException, @@ -34,7 +32,6 @@ import type { OsTypeArray, ExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { ListOperatorTypeEnum as OperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { DataViewBase } from '@kbn/es-query'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; @@ -317,32 +314,6 @@ describe('Exception helpers', () => { }); }); - describe('#entryHasListType', () => { - test('it should return false with an empty array', () => { - const payload: ExceptionListItemSchema[] = []; - const result = entryHasListType(payload); - expect(result).toEqual(false); - }); - - test("it should return false with exception items that don't contain a list type", () => { - const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; - const result = entryHasListType(payload); - expect(result).toEqual(false); - }); - - test('it should return true with exception items that do contain a list type', () => { - const payload = [ - { - ...getExceptionListItemSchemaMock(), - entries: [{ type: OperatorTypeEnum.LIST }] as EntriesArray, - }, - getExceptionListItemSchemaMock(), - ]; - const result = entryHasListType(payload); - expect(result).toEqual(true); - }); - }); - describe('#getCodeSignatureValue', () => { test('it should return empty string if code_signature nested value are undefined', () => { // Using the unsafe casting because with our types this shouldn't be possible but there have been issues with old data having undefined values in these fields @@ -354,47 +325,6 @@ describe('Exception helpers', () => { }); }); - describe('#entryHasNonEcsType', () => { - const mockEcsIndexPattern = { - title: 'testIndex', - fields: [ - { - name: 'some.parentField', - }, - { - name: 'some.not.nested.field', - }, - { - name: 'nested.field', - }, - ], - } as DataViewBase; - - test('it should return false with an empty array', () => { - const payload: ExceptionListItemSchema[] = []; - const result = entryHasNonEcsType(payload, mockEcsIndexPattern); - expect(result).toEqual(false); - }); - - test("it should return false with exception items that don't contain a non ecs type", () => { - const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; - const result = entryHasNonEcsType(payload, mockEcsIndexPattern); - expect(result).toEqual(false); - }); - - test('it should return true with exception items that do contain a non ecs type', () => { - const payload = [ - { - ...getExceptionListItemSchemaMock(), - entries: [{ field: 'some.nonEcsField' }] as EntriesArray, - }, - getExceptionListItemSchemaMock(), - ]; - const result = entryHasNonEcsType(payload, mockEcsIndexPattern); - expect(result).toEqual(true); - }); - }); - describe('#prepareExceptionItemsForBulkClose', () => { test('it should return no exceptionw when passed in an empty array', () => { const payload: ExceptionListItemSchema[] = []; @@ -509,7 +439,7 @@ describe('Exception helpers', () => { test('it returns prepopulated fields with empty values', () => { const prepopulatedItem = getPrepopulatedEndpointException({ listId: 'some_id', - ruleName: 'my rule', + name: 'my rule', codeSignature: { subjectName: '', trusted: '' }, eventCode: '', alertEcsData: { ...alertDataMock, file: { path: '', hash: { sha256: '' } } }, @@ -534,7 +464,7 @@ describe('Exception helpers', () => { test('it returns prepopulated items with actual values', () => { const prepopulatedItem = getPrepopulatedEndpointException({ listId: 'some_id', - ruleName: 'my rule', + name: 'my rule', codeSignature: { subjectName: 'someSubjectName', trusted: 'false' }, eventCode: 'some-event-code', alertEcsData: alertDataMock, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx index f876f11be9e3..41d588a23763 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx @@ -21,26 +21,19 @@ import type { OsTypeArray, ExceptionListType, ExceptionListItemSchema, - CreateExceptionListItemSchema, UpdateExceptionListItemSchema, ExceptionListSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { - comment, - osType, - ListOperatorTypeEnum as OperatorTypeEnum, -} from '@kbn/securitysolution-io-ts-list-types'; +import { comment, osType } from '@kbn/securitysolution-io-ts-list-types'; import type { ExceptionsBuilderExceptionItem, ExceptionsBuilderReturnExceptionItem, } from '@kbn/securitysolution-list-utils'; -import { - getOperatorType, - getNewExceptionItem, - addIdToEntries, -} from '@kbn/securitysolution-list-utils'; +import { getNewExceptionItem, addIdToEntries } from '@kbn/securitysolution-list-utils'; import type { DataViewBase } from '@kbn/es-query'; +import { removeIdFromExceptionItemsEntries } from '@kbn/securitysolution-list-hooks'; + import * as i18n from './translations'; import type { AlertData, Flattened } from './types'; @@ -145,9 +138,9 @@ export const formatExceptionItemForUpdate = ( * @param exceptionItems new or existing ExceptionItem[] */ export const prepareExceptionItemsForBulkClose = ( - exceptionItems: Array -): Array => { - return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { + exceptionItems: ExceptionListItemSchema[] +): ExceptionListItemSchema[] => { + return exceptionItems.map((item: ExceptionListItemSchema) => { if (item.entries !== undefined) { const newEntries = item.entries.map((itemEntry: Entry | EntryNested) => { return { @@ -285,17 +278,6 @@ export const lowercaseHashValues = ( }); }; -export const entryHasListType = (exceptionItems: ExceptionsBuilderReturnExceptionItem[]) => { - for (const { entries } of exceptionItems) { - for (const exceptionEntry of entries ?? []) { - if (getOperatorType(exceptionEntry) === OperatorTypeEnum.LIST) { - return true; - } - } - } - return false; -}; - /** * Returns the value for `file.Ext.code_signature` which * can be an object or array of objects @@ -377,7 +359,7 @@ function filterEmptyExceptionEntries(entries: T[]): T[ */ export const getPrepopulatedEndpointException = ({ listId, - ruleName, + name, codeSignature, eventCode, listNamespace = 'agnostic', @@ -385,7 +367,7 @@ export const getPrepopulatedEndpointException = ({ }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; codeSignature: { subjectName: string; trusted: string }; eventCode: string; alertEcsData: Flattened; @@ -449,7 +431,7 @@ export const getPrepopulatedEndpointException = ({ }; return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: entriesToAdd(), }; }; @@ -459,7 +441,7 @@ export const getPrepopulatedEndpointException = ({ */ export const getPrepopulatedRansomwareException = ({ listId, - ruleName, + name, codeSignature, eventCode, listNamespace = 'agnostic', @@ -467,7 +449,7 @@ export const getPrepopulatedRansomwareException = ({ }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; codeSignature: { subjectName: string; trusted: string }; eventCode: string; alertEcsData: Flattened; @@ -477,7 +459,7 @@ export const getPrepopulatedRansomwareException = ({ const executable = process?.executable ?? ''; const ransomwareFeature = Ransomware?.feature ?? ''; return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: addIdToEntries([ { field: 'process.Ext.code_signature', @@ -527,14 +509,14 @@ export const getPrepopulatedRansomwareException = ({ export const getPrepopulatedMemorySignatureException = ({ listId, - ruleName, + name, eventCode, listNamespace = 'agnostic', alertEcsData, }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; eventCode: string; alertEcsData: Flattened; }): ExceptionsBuilderExceptionItem => { @@ -566,20 +548,20 @@ export const getPrepopulatedMemorySignatureException = ({ }, ]); return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: addIdToEntries(entries), }; }; export const getPrepopulatedMemoryShellcodeException = ({ listId, - ruleName, + name, eventCode, listNamespace = 'agnostic', alertEcsData, }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; eventCode: string; alertEcsData: Flattened; }): ExceptionsBuilderExceptionItem => { @@ -618,21 +600,21 @@ export const getPrepopulatedMemoryShellcodeException = ({ ]); return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: addIdToEntries(entries), }; }; export const getPrepopulatedBehaviorException = ({ listId, - ruleName, + name, eventCode, listNamespace = 'agnostic', alertEcsData, }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; eventCode: string; alertEcsData: Flattened; }): ExceptionsBuilderExceptionItem => { @@ -748,47 +730,17 @@ export const getPrepopulatedBehaviorException = ({ }, ]); return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: addIdToEntries(entries), }; }; -/** - * Determines whether or not any entries within the given exceptionItems contain values not in the specified ECS mapping - */ -export const entryHasNonEcsType = ( - exceptionItems: ExceptionsBuilderReturnExceptionItem[], - indexPatterns: DataViewBase -): boolean => { - const doesFieldNameExist = (exceptionEntry: Entry): boolean => { - return indexPatterns.fields.some(({ name }) => name === exceptionEntry.field); - }; - - if (exceptionItems.length === 0) { - return false; - } - for (const { entries } of exceptionItems) { - for (const exceptionEntry of entries ?? []) { - if (exceptionEntry.type === 'nested') { - for (const nestedExceptionEntry of exceptionEntry.entries) { - if (doesFieldNameExist(nestedExceptionEntry) === false) { - return true; - } - } - } else if (doesFieldNameExist(exceptionEntry) === false) { - return true; - } - } - } - return false; -}; - /** * Returns the default values from the alert data to autofill new endpoint exceptions */ export const defaultEndpointExceptionItems = ( listId: string, - ruleName: string, + name: string, alertEcsData: Flattened & { 'event.code'?: string } ): ExceptionsBuilderExceptionItem[] => { const eventCode = alertEcsData['event.code'] ?? alertEcsData.event?.code; @@ -798,7 +750,7 @@ export const defaultEndpointExceptionItems = ( return [ getPrepopulatedBehaviorException({ listId, - ruleName, + name, eventCode, alertEcsData, }), @@ -807,7 +759,7 @@ export const defaultEndpointExceptionItems = ( return [ getPrepopulatedMemorySignatureException({ listId, - ruleName, + name, eventCode, alertEcsData, }), @@ -816,7 +768,7 @@ export const defaultEndpointExceptionItems = ( return [ getPrepopulatedMemoryShellcodeException({ listId, - ruleName, + name, eventCode, alertEcsData, }), @@ -825,7 +777,7 @@ export const defaultEndpointExceptionItems = ( return getProcessCodeSignature(alertEcsData).map((codeSignature) => getPrepopulatedRansomwareException({ listId, - ruleName, + name, eventCode, codeSignature, alertEcsData, @@ -836,7 +788,7 @@ export const defaultEndpointExceptionItems = ( return getFileCodeSignature(alertEcsData).map((codeSignature) => getPrepopulatedEndpointException({ listId, - ruleName, + name, eventCode: eventCode ?? '', codeSignature, alertEcsData, @@ -872,7 +824,7 @@ export const enrichRuleExceptions = ( ): ExceptionsBuilderReturnExceptionItem[] => { return exceptionItems.map((item: ExceptionsBuilderReturnExceptionItem) => { return { - ...item, + ...removeIdFromExceptionItemsEntries(item), list_id: undefined, namespace_type: 'single', }; @@ -891,7 +843,7 @@ export const enrichSharedExceptions = ( return lists.flatMap((list) => { return exceptionItems.map((item) => { return { - ...item, + ...removeIdFromExceptionItemsEntries(item), list_id: list.list_id, namespace_type: list.namespace_type, }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 25853c932a18..03141dfb02f5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -11,7 +11,7 @@ import { EuiButtonIcon, EuiContextMenuPanel, EuiPopover, EuiToolTip } from '@ela import { indexOf } from 'lodash'; import type { ConnectedProps } from 'react-redux'; import { connect } from 'react-redux'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { get } from 'lodash/fp'; import { DEFAULT_ACTION_BUTTON_WIDTH } from '@kbn/timelines-plugin/public'; import { isActiveTimeline } from '../../../../helpers'; @@ -42,6 +42,7 @@ import { ATTACH_ALERT_TO_CASE_FOR_ROW } from '../../../../timelines/components/t import { useEventFilterAction } from './use_event_filter_action'; import { useAddToCaseActions } from './use_add_to_case_actions'; import { isAlertFromEndpointAlert } from '../../../../common/utils/endpoint_alert_check'; +import type { Rule } from '../../../containers/detection_engine/rules/types'; interface AlertContextMenuProps { ariaLabel?: string; @@ -100,7 +101,6 @@ const AlertContextMenuComponent: React.FC indexOf(ecsRowData.event?.kind, 'event') !== -1, [ecsRowData]); const isAgentEndpoint = useMemo(() => ecsRowData.agent?.type?.includes('endpoint'), [ecsRowData]); - const isEndpointEvent = useMemo(() => isEvent && isAgentEndpoint, [isEvent, isAgentEndpoint]); const scopeIdAllowsAddEndpointEventFilter = useMemo( () => scopeId === TableId.hostsPageEvents || scopeId === TableId.usersPageEvents, @@ -147,16 +147,19 @@ const AlertContextMenuComponent: React.FC { + (type?: ExceptionListTypeEnum) => { onAddExceptionTypeClick(type); closePopover(); }, @@ -251,22 +254,19 @@ const AlertContextMenuComponent: React.FC
)} - {exceptionFlyoutType != null && - ruleId != null && - ruleName != null && - ecsRowData?._id != null && ( - - )} + {openAddExceptionFlyout && ruleId != null && ruleName != null && ecsRowData?._id != null && ( + + )} {isAddEventFilterModalOpen && ecsRowData != null && ( )} @@ -301,9 +301,19 @@ export const AlertContextMenu = connector(React.memo(AlertContextMenuComponent)) type AddExceptionFlyoutWrapperProps = Omit< AddExceptionFlyoutProps, - 'alertData' | 'isAlertDataLoading' + | 'alertData' + | 'isAlertDataLoading' + | 'isEndpointItem' + | 'rules' + | 'isBulkAction' + | 'showAlertCloseOptions' > & { eventId?: string; + ruleId: Rule['id']; + ruleIndices: Rule['index']; + ruleDataViewId: Rule['data_view_id']; + ruleName: Rule['name']; + exceptionListType: ExceptionListTypeEnum | null; }; /** @@ -312,15 +322,15 @@ type AddExceptionFlyoutWrapperProps = Omit< * we cannot use the fetch hook within the flyout component itself */ export const AddExceptionFlyoutWrapper: React.FC = ({ - ruleName, ruleId, ruleIndices, + ruleDataViewId, + ruleName, exceptionListType, eventId, onCancel, onConfirm, alertStatus, - onRuleChange, }) => { const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); @@ -354,8 +364,8 @@ export const AddExceptionFlyoutWrapper: React.FC ? enrichedAlert.signal.rule.index : [enrichedAlert.signal.rule.index]; } - return []; - }, [enrichedAlert]); + return ruleIndices; + }, [enrichedAlert, ruleIndices]); const memoDataViewId = useMemo(() => { if ( @@ -364,23 +374,51 @@ export const AddExceptionFlyoutWrapper: React.FC ) { return enrichedAlert['kibana.alert.rule.parameters'].data_view_id; } - }, [enrichedAlert]); - const isLoading = isLoadingAlertData && isSignalIndexLoading; + return ruleDataViewId; + }, [enrichedAlert, ruleDataViewId]); + + // TODO: Do we want to notify user when they are working off of an older version of a rule + // if they select to add an exception from an alert referencing an older rule version? + const memoRule = useMemo(() => { + if (enrichedAlert != null && enrichedAlert['kibana.alert.rule.parameters'] != null) { + return [ + { + ...enrichedAlert['kibana.alert.rule.parameters'], + id: ruleId, + name: ruleName, + index: memoRuleIndices, + data_view_id: memoDataViewId, + }, + ] as Rule[]; + } + + return [ + { + id: ruleId, + name: ruleName, + index: memoRuleIndices, + data_view_id: memoDataViewId, + }, + ] as Rule[]; + }, [enrichedAlert, memoDataViewId, memoRuleIndices, ruleId, ruleName]); + + const isLoading = + (isLoadingAlertData && isSignalIndexLoading) || + enrichedAlert == null || + memoRuleIndices == null; return ( ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx index e0a09be0873d..e63cbcc4c22d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx @@ -7,14 +7,14 @@ import React, { useCallback, useMemo } from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { useUserData } from '../../user_info'; import { ACTION_ADD_ENDPOINT_EXCEPTION, ACTION_ADD_EXCEPTION } from '../translations'; interface UseExceptionActionProps { isEndpointAlert: boolean; - onAddExceptionTypeClick: (type: ExceptionListType) => void; + onAddExceptionTypeClick: (type?: ExceptionListTypeEnum) => void; } export const useExceptionActions = ({ @@ -24,11 +24,11 @@ export const useExceptionActions = ({ const [{ canUserCRUD, hasIndexWrite }] = useUserData(); const handleDetectionExceptionModal = useCallback(() => { - onAddExceptionTypeClick('detection'); + onAddExceptionTypeClick(); }, [onAddExceptionTypeClick]); const handleEndpointExceptionModal = useCallback(() => { - onAddExceptionTypeClick('endpoint'); + onAddExceptionTypeClick(ExceptionListTypeEnum.ENDPOINT); }, [onAddExceptionTypeClick]); const disabledAddEndpointException = !canUserCRUD || !hasIndexWrite || !isEndpointAlert; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx index aff1c943110c..85892f4ba5b5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx @@ -5,63 +5,66 @@ * 2.0. */ -import { useCallback, useMemo, useState } from 'react'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import { useCallback, useState } from 'react'; +import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; import type { inputsModel } from '../../../../common/store'; interface UseExceptionFlyoutProps { - ruleIndex: string[] | null | undefined; refetch?: inputsModel.Refetch; + onRuleChange?: () => void; isActiveTimelines: boolean; } interface UseExceptionFlyout { - exceptionFlyoutType: ExceptionListType | null; - onAddExceptionTypeClick: (type: ExceptionListType) => void; - onAddExceptionCancel: () => void; - onAddExceptionConfirm: (didCloseAlert: boolean, didBulkCloseAlert: boolean) => void; - ruleIndices: string[]; + exceptionFlyoutType: ExceptionListTypeEnum | null; + openAddExceptionFlyout: boolean; + onAddExceptionTypeClick: (type?: ExceptionListTypeEnum) => void; + onAddExceptionCancel: (didRuleChange: boolean) => void; + onAddExceptionConfirm: ( + didRuleChange: boolean, + didCloseAlert: boolean, + didBulkCloseAlert: boolean + ) => void; } export const useExceptionFlyout = ({ - ruleIndex, refetch, + onRuleChange, isActiveTimelines, }: UseExceptionFlyoutProps): UseExceptionFlyout => { - const [exceptionFlyoutType, setOpenAddExceptionFlyout] = useState(null); - - const ruleIndices = useMemo((): string[] => { - if (ruleIndex != null) { - return ruleIndex; - } else { - return DEFAULT_INDEX_PATTERN; - } - }, [ruleIndex]); + const [openAddExceptionFlyout, setOpenAddExceptionFlyout] = useState(false); + const [exceptionFlyoutType, setExceptionFlyoutType] = useState( + null + ); - const onAddExceptionTypeClick = useCallback((exceptionListType: ExceptionListType): void => { - setOpenAddExceptionFlyout(exceptionListType); + const onAddExceptionTypeClick = useCallback((exceptionListType?: ExceptionListTypeEnum): void => { + setExceptionFlyoutType(exceptionListType ?? null); + setOpenAddExceptionFlyout(true); }, []); const onAddExceptionCancel = useCallback(() => { - setOpenAddExceptionFlyout(null); + setExceptionFlyoutType(null); + setOpenAddExceptionFlyout(false); }, []); const onAddExceptionConfirm = useCallback( - (didCloseAlert: boolean, didBulkCloseAlert) => { + (didRuleChange: boolean, didCloseAlert: boolean, didBulkCloseAlert) => { if (refetch && (isActiveTimelines === false || didBulkCloseAlert)) { refetch(); } - setOpenAddExceptionFlyout(null); + if (onRuleChange != null && didRuleChange) { + onRuleChange(); + } + setOpenAddExceptionFlyout(false); }, - [refetch, isActiveTimelines] + [onRuleChange, refetch, isActiveTimelines] ); return { exceptionFlyoutType, + openAddExceptionFlyout, onAddExceptionTypeClick, onAddExceptionCancel, onAddExceptionConfirm, - ruleIndices, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index ba641875b250..f4364443a6df 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiButton, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { isActiveTimeline } from '../../../helpers'; import { TableId } from '../../../../common/types'; import { useResponderActionItem } from '../endpoint_responder'; @@ -45,7 +45,7 @@ export interface TakeActionDropdownProps { isHostIsolationPanelOpen: boolean; loadingEventDetails: boolean; onAddEventFilterClick: () => void; - onAddExceptionTypeClick: (type: ExceptionListType) => void; + onAddExceptionTypeClick: (type?: ExceptionListTypeEnum) => void; onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void; refetch: (() => void) | undefined; refetchFlyoutData: () => Promise; @@ -144,7 +144,7 @@ export const TakeActionDropdown = React.memo( ); const handleOnAddExceptionTypeClick = useCallback( - (type: ExceptionListType) => { + (type?: ExceptionListTypeEnum) => { onAddExceptionTypeClick(type); setIsPopoverOpen(false); }, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/exceptions/get_es_query_filter.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/exceptions/get_es_query_filter.ts index bab81d4625f0..2b280786e290 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/exceptions/get_es_query_filter.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/exceptions/get_es_query_filter.ts @@ -6,13 +6,11 @@ */ import type { Language } from '@kbn/securitysolution-io-ts-alerting-types'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; import type { Filter, EsQueryConfig, DataViewBase } from '@kbn/es-query'; import { getExceptionFilterFromExceptions } from '@kbn/securitysolution-list-api'; import { buildEsQuery } from '@kbn/es-query'; + +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { KibanaServices } from '../../../../common/lib/kibana'; import type { Query, Index } from '../../../../../common/detection_engine/schemas/common'; @@ -23,7 +21,7 @@ export const getEsQueryFilter = async ( language: Language, filters: unknown, index: Index, - lists: Array, + lists: ExceptionListItemSchema[], excludeExceptions: boolean = true ): Promise => { const indexPattern: DataViewBase = { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 1a1238c36ca3..e2c1e0d31cd5 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -8,6 +8,10 @@ import { camelCase } from 'lodash'; import type { HttpStart } from '@kbn/core/public'; +import type { + CreateRuleExceptionListItemSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { DETECTION_ENGINE_RULES_URL, DETECTION_ENGINE_PREPACKAGED_URL, @@ -406,3 +410,30 @@ export const findRuleExceptionReferences = async ({ } ); }; + +/** + * Add exception items to default rule exception list + * + * @param ruleId `id` of rule to add items to + * @param items CreateRuleExceptionListItemSchema[] + * @param signal to cancel request + * + * @throws An error if response is not OK + */ +export const addRuleExceptions = async ({ + ruleId, + items, + signal, +}: { + ruleId: string; + items: CreateRuleExceptionListItemSchema[]; + signal: AbortSignal | undefined; +}): Promise => + KibanaServices.get().http.fetch( + `${DETECTION_ENGINE_RULES_URL}/${ruleId}/exceptions`, + { + method: 'POST', + body: JSON.stringify({ items }), + signal, + } + ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 97c302d7038c..2989b77ec28d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -866,7 +866,10 @@ const RuleDetailsPageComponent: React.FC = ({ = ({ > { const alertId = detailsEcsData?.kibana?.alert ? detailsEcsData?._id : null; - const ruleIndex = useMemo( + const ruleIndexRaw = useMemo( () => find({ category: 'signal', field: 'signal.rule.index' }, detailsData)?.values ?? find({ category: 'kibana', field: 'kibana.alert.rule.parameters.index' }, detailsData) ?.values, [detailsData] ); + const ruleIndex = useMemo( + (): string[] | undefined => (Array.isArray(ruleIndexRaw) ? ruleIndexRaw : undefined), + [ruleIndexRaw] + ); + const ruleDataViewIdRaw = useMemo( + () => + find({ category: 'signal', field: 'signal.rule.data_view_id' }, detailsData)?.values ?? + find( + { category: 'kibana', field: 'kibana.alert.rule.parameters.data_view_id' }, + detailsData + )?.values, + [detailsData] + ); + const ruleDataViewId = useMemo( + (): string | undefined => + Array.isArray(ruleDataViewIdRaw) ? ruleDataViewIdRaw[0] : undefined, + [ruleDataViewIdRaw] + ); const addExceptionModalWrapperData = useMemo( () => @@ -102,12 +120,11 @@ export const FlyoutFooterComponent = React.memo( const { exceptionFlyoutType, + openAddExceptionFlyout, onAddExceptionTypeClick, onAddExceptionCancel, onAddExceptionConfirm, - ruleIndices, } = useExceptionFlyout({ - ruleIndex, refetch: refetchAll, isActiveTimelines: isActiveTimeline(scopeId), }); @@ -154,12 +171,13 @@ export const FlyoutFooterComponent = React.memo( {/* This is still wrong to do render flyout/modal inside of the flyout We need to completely refactor the EventDetails component to be correct */} - {exceptionFlyoutType != null && + {openAddExceptionFlyout && addExceptionModalWrapperData.ruleId != null && addExceptionModalWrapperData.eventId != null && ( `exception-list.attributes.list_id:${listId}`) + .map( + (listId, index) => + `${getSavedObjectType({ + namespaceType: namespaceTypes[index], + })}.attributes.list_id:${listId}` + ) .join(' OR ')})` : undefined, - namespaceType: ['agnostic', 'single'], + namespaceType: namespaceTypes, page: 1, perPage: 10000, sortField: undefined, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 76d8a094a43a..cb1903d4a961 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -25624,7 +25624,6 @@ "xpack.securitySolution.eventsTab.unit": "{totalCount, plural, =1 {alerte externe} other {alertes externes}}", "xpack.securitySolution.eventsViewer.unit": "{totalCount, plural, =1 {événement} other {événements}}", "xpack.securitySolution.exceptions.dissasociateListSuccessText": "La liste d'exceptions ({id}) a été retirée avec succès", - "xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel": "Afficher {comments, plural, =1 {commentaire} other {commentaires}} ({comments})", "xpack.securitySolution.exceptions.failedLoadPolicies": "Une erreur s'est produite lors du chargement des politiques : \"{error}\"", "xpack.securitySolution.exceptions.fetch404Error": "La liste d'exceptions associée ({listId}) n'existe plus. Veuillez retirer la liste d'exceptions manquante pour ajouter des exceptions supplémentaires à la règle de détection.", "xpack.securitySolution.exceptions.hideCommentsLabel": "Masquer ({comments}) {comments, plural, =1 {commentaire} other {commentaires}}", @@ -28164,58 +28163,14 @@ "xpack.securitySolution.eventsViewer.alerts.overviewTable.signalStatusTitle": "Statut", "xpack.securitySolution.eventsViewer.eventsLabel": "Événements", "xpack.securitySolution.eventsViewer.showingLabel": "Affichage", - "xpack.securitySolution.exceptions.addException.addEndpointException": "Ajouter une exception de point de terminaison", - "xpack.securitySolution.exceptions.addException.addException": "Ajouter une exception à une règle", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel": "Fermer toutes les alertes qui correspondent à cette exception et ont été générées par cette règle", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled": "Fermer toutes les alertes qui correspondent à cette exception et ont été générées par cette règle (les listes et les champs non ECS ne sont pas pris en charge)", - "xpack.securitySolution.exceptions.addException.cancel": "Annuler", - "xpack.securitySolution.exceptions.addException.endpointQuarantineText": "Sur tous les hôtes Endpoint, les fichiers en quarantaine qui correspondent à l'exception sont automatiquement restaurés à leur emplacement d'origine. Cette exception s'applique à toutes les règles utilisant les exceptions Endpoint.", - "xpack.securitySolution.exceptions.addException.error": "Impossible d'ajouter l'exception", - "xpack.securitySolution.exceptions.addException.infoLabel": "Les alertes sont générées lorsque les conditions de la règle sont remplies, sauf quand :", - "xpack.securitySolution.exceptions.addException.operatingSystemPlaceHolder": "Sélectionner un système d'exploitation", - "xpack.securitySolution.exceptions.addException.sequenceWarning": "La requête de cette règle contient une instruction de séquence EQL. L'exception créée s'appliquera à tous les événements de la séquence.", - "xpack.securitySolution.exceptions.addException.success": "Exception ajoutée avec succès", "xpack.securitySolution.exceptions.badge.readOnly.tooltip": "Impossible de créer, de modifier ou de supprimer des exceptions", "xpack.securitySolution.exceptions.cancelLabel": "Annuler", "xpack.securitySolution.exceptions.clearExceptionsLabel": "Retirer la liste d'exceptions", "xpack.securitySolution.exceptions.commentEventLabel": "a ajouté un commentaire", "xpack.securitySolution.exceptions.dissasociateExceptionListError": "Impossible de retirer la liste d'exceptions", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel": "Fermer toutes les alertes qui correspondent à cette exception et ont été générées par cette règle", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled": "Fermer toutes les alertes qui correspondent à cette exception et ont été générées par cette règle (les listes et les champs non ECS ne sont pas pris en charge)", - "xpack.securitySolution.exceptions.editException.cancel": "Annuler", - "xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle": "Modifier une exception de point de terminaison", - "xpack.securitySolution.exceptions.editException.editExceptionSaveButton": "Enregistrer", - "xpack.securitySolution.exceptions.editException.editExceptionTitle": "Modifier une exception à une règle", - "xpack.securitySolution.exceptions.editException.endpointQuarantineText": "Sur tous les hôtes Endpoint, les fichiers en quarantaine qui correspondent à l'exception sont automatiquement restaurés à leur emplacement d'origine. Cette exception s'applique à toutes les règles utilisant les exceptions Endpoint.", - "xpack.securitySolution.exceptions.editException.infoLabel": "Les alertes sont générées lorsque les conditions de la règle sont remplies, sauf quand :", - "xpack.securitySolution.exceptions.editException.sequenceWarning": "La requête de cette règle contient une instruction de séquence EQL. L'exception modifiée s'appliquera à tous les événements de la séquence.", - "xpack.securitySolution.exceptions.editException.success": "L'exception a été mise à jour avec succès", - "xpack.securitySolution.exceptions.editException.versionConflictDescription": "Cette exception semble avoir été mise à jour depuis que vous l'avez sélectionnée pour la modifier. Essayez de cliquer sur \"Annuler\" et de modifier à nouveau l'exception.", - "xpack.securitySolution.exceptions.editException.versionConflictTitle": "Désolé, une erreur est survenue", "xpack.securitySolution.exceptions.errorLabel": "Erreur", "xpack.securitySolution.exceptions.fetchError": "Erreur lors de la récupération de la liste d'exceptions", "xpack.securitySolution.exceptions.modalErrorAccordionText": "Afficher les informations de référence de la règle :", - "xpack.securitySolution.exceptions.exceptionItem.conditions.and": "AND", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator": "existe", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator.not": "n'existe pas", - "xpack.securitySolution.exceptions.exceptionItem.conditions.linux": "Linux", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator": "inclus dans", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator.not": "n'est pas inclus dans", - "xpack.securitySolution.exceptions.exceptionItem.conditions.macos": "Mac", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator": "est l'une des options suivantes", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not": "n'est pas l'une des options suivantes", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator": "IS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not": "N'EST PAS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator": "a", - "xpack.securitySolution.exceptions.exceptionItem.conditions.os": "Système d'exploitation", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator": "NE CORRESPOND PAS À", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator": "CORRESPONDANCES", - "xpack.securitySolution.exceptions.exceptionItem.conditions.windows": "Windows", - "xpack.securitySolution.exceptions.exceptionItem.createdLabel": "Créé", - "xpack.securitySolution.exceptions.exceptionItem.deleteItemButton": "Supprimer un élément", - "xpack.securitySolution.exceptions.exceptionItem.editItemButton": "Modifier l’élément", - "xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy": "par", - "xpack.securitySolution.exceptions.exceptionItem.updatedLabel": "Mis à jour", "xpack.securitySolution.exceptions.operatingSystemFullLabel": "Système d'exploitation", "xpack.securitySolution.exceptions.operatingSystemLinux": "Linux", "xpack.securitySolution.exceptions.operatingSystemMac": "macOS", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a03f2a024c21..81bd0f3d33b0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25599,7 +25599,6 @@ "xpack.securitySolution.eventsTab.unit": "外部{totalCount, plural, other {アラート}}", "xpack.securitySolution.eventsViewer.unit": "{totalCount, plural, other {イベント}}", "xpack.securitySolution.exceptions.dissasociateListSuccessText": "例外リスト({id})が正常に削除されました", - "xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel": "{comments, plural, other {件のコメント}}を表示({comments})", "xpack.securitySolution.exceptions.failedLoadPolicies": "ポリシーの読み込みエラーが発生しました:\"{error}\"", "xpack.securitySolution.exceptions.fetch404Error": "関連付けられた例外リスト({listId})は存在しません。その他の例外を検出ルールに追加するには、見つからない例外リストを削除してください。", "xpack.securitySolution.exceptions.hideCommentsLabel": "({comments}){comments, plural, other {件のコメント}}を非表示", @@ -28139,57 +28138,13 @@ "xpack.securitySolution.eventsViewer.alerts.overviewTable.signalStatusTitle": "ステータス", "xpack.securitySolution.eventsViewer.eventsLabel": "イベント", "xpack.securitySolution.eventsViewer.showingLabel": "表示中", - "xpack.securitySolution.exceptions.addException.addEndpointException": "エンドポイント例外の追加", - "xpack.securitySolution.exceptions.addException.addException": "ルール例外の追加", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel": "この例外一致し、このルールによって生成された、すべてのアラートを閉じる", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled": "この例外と一致し、このルールによって生成された、すべてのアラートを閉じる(リストと非ECSフィールドはサポートされません)", - "xpack.securitySolution.exceptions.addException.cancel": "キャンセル", - "xpack.securitySolution.exceptions.addException.endpointQuarantineText": "すべてのエンドポイントホストで、例外と一致する隔離されたファイルは、自動的に元の場所に復元されます。この例外はエンドポイント例外を使用するすべてのルールに適用されます。", - "xpack.securitySolution.exceptions.addException.error": "例外を追加できませんでした", - "xpack.securitySolution.exceptions.addException.infoLabel": "ルールの条件が満たされるときにアラートが生成されます。例外:", - "xpack.securitySolution.exceptions.addException.operatingSystemPlaceHolder": "オペレーティングシステムを選択", - "xpack.securitySolution.exceptions.addException.sequenceWarning": "このルールのクエリにはEQLシーケンス文があります。作成された例外は、シーケンスのすべてのイベントに適用されます。", - "xpack.securitySolution.exceptions.addException.success": "正常に例外を追加しました", "xpack.securitySolution.exceptions.badge.readOnly.tooltip": "例外を作成、編集、削除できません", "xpack.securitySolution.exceptions.cancelLabel": "キャンセル", "xpack.securitySolution.exceptions.clearExceptionsLabel": "例外リストを削除", "xpack.securitySolution.exceptions.commentEventLabel": "コメントを追加しました", "xpack.securitySolution.exceptions.dissasociateExceptionListError": "例外リストを削除できませんでした", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel": "この例外一致し、このルールによって生成された、すべてのアラートを閉じる", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled": "この例外と一致し、このルールによって生成された、すべてのアラートを閉じる(リストと非ECSフィールドはサポートされません)", - "xpack.securitySolution.exceptions.editException.cancel": "キャンセル", - "xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle": "エンドポイント例外の編集", - "xpack.securitySolution.exceptions.editException.editExceptionSaveButton": "保存", - "xpack.securitySolution.exceptions.editException.editExceptionTitle": "ルール例外を編集", - "xpack.securitySolution.exceptions.editException.endpointQuarantineText": "すべてのエンドポイントホストで、例外と一致する隔離されたファイルは、自動的に元の場所に復元されます。この例外はエンドポイント例外を使用するすべてのルールに適用されます。", - "xpack.securitySolution.exceptions.editException.infoLabel": "ルールの条件が満たされるときにアラートが生成されます。例外:", - "xpack.securitySolution.exceptions.editException.sequenceWarning": "このルールのクエリにはEQLシーケンス文があります。修正された例外は、シーケンスのすべてのイベントに適用されます。", - "xpack.securitySolution.exceptions.editException.success": "正常に例外を更新しました", - "xpack.securitySolution.exceptions.editException.versionConflictDescription": "最初に編集することを選択したときからこの例外が更新されている可能性があります。[キャンセル]をクリックし、もう一度例外を編集してください。", - "xpack.securitySolution.exceptions.editException.versionConflictTitle": "申し訳ございません、エラーが発生しました", "xpack.securitySolution.exceptions.errorLabel": "エラー", "xpack.securitySolution.exceptions.fetchError": "例外リストの取得エラー", - "xpack.securitySolution.exceptions.exceptionItem.conditions.and": "AND", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator": "存在する", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator.not": "存在しない", - "xpack.securitySolution.exceptions.exceptionItem.conditions.linux": "Linux", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator": "に含まれる", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator.not": "に含まれない", - "xpack.securitySolution.exceptions.exceptionItem.conditions.macos": "Mac", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator": "is one of", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not": "is not one of", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator": "IS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not": "IS NOT", - "xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator": "がある", - "xpack.securitySolution.exceptions.exceptionItem.conditions.os": "OS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator": "一致しない", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator": "一致", - "xpack.securitySolution.exceptions.exceptionItem.conditions.windows": "Windows", - "xpack.securitySolution.exceptions.exceptionItem.createdLabel": "作成済み", - "xpack.securitySolution.exceptions.exceptionItem.deleteItemButton": "アイテムを削除", - "xpack.securitySolution.exceptions.exceptionItem.editItemButton": "項目を編集", - "xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy": "グループ基準", - "xpack.securitySolution.exceptions.exceptionItem.updatedLabel": "更新しました", "xpack.securitySolution.exceptions.modalErrorAccordionText": "ルール参照情報を表示:", "xpack.securitySolution.exceptions.operatingSystemFullLabel": "オペレーティングシステム", "xpack.securitySolution.exceptions.operatingSystemLinux": "Linux", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 193d57ba8f62..c1d8961ca766 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25633,7 +25633,6 @@ "xpack.securitySolution.eventsTab.unit": "个外部{totalCount, plural, other {告警}}", "xpack.securitySolution.eventsViewer.unit": "{totalCount, plural, other {个事件}}", "xpack.securitySolution.exceptions.dissasociateListSuccessText": "例外列表 ({id}) 已成功移除", - "xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel": "显示{comments, plural, other {注释}} ({comments})", "xpack.securitySolution.exceptions.failedLoadPolicies": "加载策略时出错:“{error}”", "xpack.securitySolution.exceptions.fetch404Error": "关联的例外列表 ({listId}) 已不存在。请移除缺少的例外列表,以将其他例外添加到检测规则。", "xpack.securitySolution.exceptions.hideCommentsLabel": "隐藏 ({comments}) 个{comments, plural, other {注释}}", @@ -28173,57 +28172,13 @@ "xpack.securitySolution.eventsViewer.alerts.overviewTable.signalStatusTitle": "状态", "xpack.securitySolution.eventsViewer.eventsLabel": "事件", "xpack.securitySolution.eventsViewer.showingLabel": "正在显示", - "xpack.securitySolution.exceptions.addException.addEndpointException": "添加终端例外", - "xpack.securitySolution.exceptions.addException.addException": "添加规则例外", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel": "关闭所有与此例外匹配且根据此规则生成的告警", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled": "关闭所有与此例外匹配且根据此规则生成的告警(不支持列表和非 ECS 字段)", - "xpack.securitySolution.exceptions.addException.cancel": "取消", - "xpack.securitySolution.exceptions.addException.endpointQuarantineText": "在所有终端主机上,与该例外匹配的已隔离文件会自动还原到其原始位置。此例外适用于使用终端例外的所有规则。", - "xpack.securitySolution.exceptions.addException.error": "添加例外失败", - "xpack.securitySolution.exceptions.addException.infoLabel": "满足规则的条件时生成告警,但以下情况除外:", - "xpack.securitySolution.exceptions.addException.operatingSystemPlaceHolder": "选择操作系统", - "xpack.securitySolution.exceptions.addException.sequenceWarning": "此规则的查询包含 EQL 序列语句。创建的例外将应用于序列中的所有事件。", - "xpack.securitySolution.exceptions.addException.success": "已成功添加例外", "xpack.securitySolution.exceptions.badge.readOnly.tooltip": "无法创建、编辑或删除例外", "xpack.securitySolution.exceptions.cancelLabel": "取消", "xpack.securitySolution.exceptions.clearExceptionsLabel": "移除例外列表", "xpack.securitySolution.exceptions.commentEventLabel": "已添加注释", "xpack.securitySolution.exceptions.dissasociateExceptionListError": "无法移除例外列表", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel": "关闭所有与此例外匹配且根据此规则生成的告警", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled": "关闭所有与此例外匹配且根据此规则生成的告警(不支持列表和非 ECS 字段)", - "xpack.securitySolution.exceptions.editException.cancel": "取消", - "xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle": "编辑终端例外", - "xpack.securitySolution.exceptions.editException.editExceptionSaveButton": "保存", - "xpack.securitySolution.exceptions.editException.editExceptionTitle": "编辑规则例外", - "xpack.securitySolution.exceptions.editException.endpointQuarantineText": "在所有终端主机上,与该例外匹配的已隔离文件会自动还原到其原始位置。此例外适用于使用终端例外的所有规则。", - "xpack.securitySolution.exceptions.editException.infoLabel": "满足规则的条件时生成告警,但以下情况除外:", - "xpack.securitySolution.exceptions.editException.sequenceWarning": "此规则的查询包含 EQL 序列语句。修改的例外将应用于序列中的所有事件。", - "xpack.securitySolution.exceptions.editException.success": "已成功更新例外", - "xpack.securitySolution.exceptions.editException.versionConflictDescription": "此例外可能自您首次选择编辑后已更新。尝试单击“取消”,重新编辑该例外。", - "xpack.securitySolution.exceptions.editException.versionConflictTitle": "抱歉,有错误", "xpack.securitySolution.exceptions.errorLabel": "错误", "xpack.securitySolution.exceptions.fetchError": "提取例外列表时出错", - "xpack.securitySolution.exceptions.exceptionItem.conditions.and": "且", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator": "存在", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator.not": "不存在", - "xpack.securitySolution.exceptions.exceptionItem.conditions.linux": "Linux", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator": "包含在", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator.not": "未包括在", - "xpack.securitySolution.exceptions.exceptionItem.conditions.macos": "Mac", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator": "属于", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not": "不属于", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator": "是", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not": "不是", - "xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator": "具有", - "xpack.securitySolution.exceptions.exceptionItem.conditions.os": "OS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator": "不匹配", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator": "匹配", - "xpack.securitySolution.exceptions.exceptionItem.conditions.windows": "Windows", - "xpack.securitySolution.exceptions.exceptionItem.createdLabel": "创建时间", - "xpack.securitySolution.exceptions.exceptionItem.deleteItemButton": "删除项", - "xpack.securitySolution.exceptions.exceptionItem.editItemButton": "编辑项目", - "xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy": "依据", - "xpack.securitySolution.exceptions.exceptionItem.updatedLabel": "已更新", "xpack.securitySolution.exceptions.modalErrorAccordionText": "显示规则引用信息:", "xpack.securitySolution.exceptions.operatingSystemFullLabel": "操作系统", "xpack.securitySolution.exceptions.operatingSystemLinux": "Linux", diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/find_rule_exception_references.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/find_rule_exception_references.ts index 7619c6f3e359..173aaa86c432 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/find_rule_exception_references.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/find_rule_exception_references.ts @@ -210,7 +210,7 @@ export default ({ getService }: FtrProviderContext) => { .get(DETECTION_ENGINE_RULES_EXCEPTIONS_REFERENCE_URL) .set('kbn-xsrf', 'true') .query({ - namespace_types: `${exceptionList.namespace_type},${exceptionList2.namespace_type}`, + namespace_types: 'single,agnostic', }) .expect(200); From 53f3034cb1b5009d955758c4f581a530209ffa3f Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Wed, 19 Oct 2022 20:32:00 +0100 Subject: [PATCH 09/43] Add readonly view to user management (#143438) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/components/use_badge.test.tsx | 77 +++++ .../security/public/components/use_badge.ts | 37 +++ .../components/use_capabilities.test.tsx | 47 +++ .../public/components/use_capabilities.ts | 32 ++ .../management/badges/readonly_badge.tsx | 33 ++ .../users/edit_user/create_user_page.test.tsx | 43 ++- .../users/edit_user/create_user_page.tsx | 10 +- .../users/edit_user/edit_user_page.test.tsx | 31 ++ .../users/edit_user/edit_user_page.tsx | 301 +++++++++--------- .../management/users/edit_user/user_form.tsx | 12 +- .../users/users_grid/users_grid_page.test.tsx | 43 ++- .../users/users_grid/users_grid_page.tsx | 38 ++- .../users/users_management_app.test.tsx | 7 +- .../management/users/users_management_app.tsx | 8 + .../server/features/security_features.ts | 4 + 15 files changed, 548 insertions(+), 175 deletions(-) create mode 100644 x-pack/plugins/security/public/components/use_badge.test.tsx create mode 100644 x-pack/plugins/security/public/components/use_badge.ts create mode 100644 x-pack/plugins/security/public/components/use_capabilities.test.tsx create mode 100644 x-pack/plugins/security/public/components/use_capabilities.ts create mode 100644 x-pack/plugins/security/public/management/badges/readonly_badge.tsx diff --git a/x-pack/plugins/security/public/components/use_badge.test.tsx b/x-pack/plugins/security/public/components/use_badge.test.tsx new file mode 100644 index 000000000000..f5d3c28e5f0b --- /dev/null +++ b/x-pack/plugins/security/public/components/use_badge.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; + +import { coreMock } from '@kbn/core/public/mocks'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; + +import type { ChromeBadge } from './use_badge'; +import { useBadge } from './use_badge'; + +describe('useBadge', () => { + it('should add badge to chrome', async () => { + const coreStart = coreMock.createStart(); + const badge: ChromeBadge = { + text: 'text', + tooltip: 'text', + }; + renderHook(useBadge, { + initialProps: badge, + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(badge); + }); + + it('should remove badge from chrome on unmount', async () => { + const coreStart = coreMock.createStart(); + const badge: ChromeBadge = { + text: 'text', + tooltip: 'text', + }; + const { unmount } = renderHook(useBadge, { + initialProps: badge, + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(badge); + + unmount(); + + expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(); + }); + + it('should update chrome when badge changes', async () => { + const coreStart = coreMock.createStart(); + const badge1: ChromeBadge = { + text: 'text', + tooltip: 'text', + }; + const { rerender } = renderHook(useBadge, { + initialProps: badge1, + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(badge1); + + const badge2: ChromeBadge = { + text: 'text2', + tooltip: 'text2', + }; + rerender(badge2); + + expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(badge2); + }); +}); diff --git a/x-pack/plugins/security/public/components/use_badge.ts b/x-pack/plugins/security/public/components/use_badge.ts new file mode 100644 index 000000000000..cd5a8d3620a2 --- /dev/null +++ b/x-pack/plugins/security/public/components/use_badge.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DependencyList } from 'react'; +import { useEffect } from 'react'; + +import type { ChromeBadge } from '@kbn/core-chrome-browser'; +import type { CoreStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +export type { ChromeBadge }; + +/** + * Renders a badge in the Kibana chrome. + * @param badge Params of the badge or `undefined` to render no badge. + * @param badge.iconType Icon type of the badge shown in the Kibana chrome. + * @param badge.text Title of tooltip displayed when hovering the badge. + * @param badge.tooltip Description of tooltip displayed when hovering the badge. + * @param deps If present, badge will be updated or removed if the values in the list change. + */ +export function useBadge( + badge: ChromeBadge | undefined, + deps: DependencyList = [badge?.iconType, badge?.text, badge?.tooltip] +) { + const { services } = useKibana(); + + useEffect(() => { + if (badge) { + services.chrome.setBadge(badge); + return () => services.chrome.setBadge(); + } + }, deps); // eslint-disable-line react-hooks/exhaustive-deps +} diff --git a/x-pack/plugins/security/public/components/use_capabilities.test.tsx b/x-pack/plugins/security/public/components/use_capabilities.test.tsx new file mode 100644 index 000000000000..b5eca83a8d53 --- /dev/null +++ b/x-pack/plugins/security/public/components/use_capabilities.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; + +import { coreMock } from '@kbn/core/public/mocks'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; + +import { useCapabilities } from './use_capabilities'; + +describe('useCapabilities', () => { + it('should return capabilities', async () => { + const coreStart = coreMock.createStart(); + + const { result } = renderHook(useCapabilities, { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toEqual(coreStart.application.capabilities); + }); + + it('should return capabilities scoped by feature', async () => { + const coreStart = coreMock.createStart(); + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + users: { + save: true, + }, + }; + + const { result } = renderHook(useCapabilities, { + initialProps: 'users', + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toEqual({ save: true }); + }); +}); diff --git a/x-pack/plugins/security/public/components/use_capabilities.ts b/x-pack/plugins/security/public/components/use_capabilities.ts new file mode 100644 index 000000000000..cdf54e2700a5 --- /dev/null +++ b/x-pack/plugins/security/public/components/use_capabilities.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Capabilities } from '@kbn/core-capabilities-common'; +import type { CoreStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +type FeatureCapabilities = Capabilities[string]; + +/** + * Returns capabilities for a specific feature, or alternatively the entire capabilities object. + * @param featureId ID of feature + */ +export function useCapabilities(): Capabilities; +export function useCapabilities( + featureId: string +): T; +export function useCapabilities( + featureId?: string +) { + const { services } = useKibana(); + + if (featureId) { + return services.application.capabilities[featureId] as T; + } + + return services.application.capabilities; +} diff --git a/x-pack/plugins/security/public/management/badges/readonly_badge.tsx b/x-pack/plugins/security/public/management/badges/readonly_badge.tsx new file mode 100644 index 000000000000..9f41ed350e15 --- /dev/null +++ b/x-pack/plugins/security/public/management/badges/readonly_badge.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import { useBadge } from '../../components/use_badge'; +import { useCapabilities } from '../../components/use_capabilities'; + +export interface ReadonlyBadgeProps { + featureId: string; + tooltip: string; +} + +export const ReadonlyBadge = ({ featureId, tooltip }: ReadonlyBadgeProps) => { + const { save } = useCapabilities(featureId); + useBadge( + save + ? undefined + : { + iconType: 'glasses', + text: i18n.translate('xpack.security.management.readonlyBadge.text', { + defaultMessage: 'Read only', + }), + tooltip, + }, + [save] + ); + return null; +}; diff --git a/x-pack/plugins/security/public/management/users/edit_user/create_user_page.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/create_user_page.test.tsx index 525df6625105..329b4bfc28b5 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/create_user_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/create_user_page.test.tsx @@ -22,12 +22,26 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ describe('CreateUserPage', () => { jest.setTimeout(15_000); + const coreStart = coreMock.createStart(); const theme$ = themeServiceMock.createTheme$(); + let history = createMemoryHistory({ initialEntries: ['/create'] }); + const authc = securityMock.createSetup().authc; + + beforeEach(() => { + history = createMemoryHistory({ initialEntries: ['/create'] }); + authc.getCurrentUser.mockClear(); + coreStart.http.delete.mockClear(); + coreStart.http.get.mockClear(); + coreStart.http.post.mockClear(); + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + users: { + save: true, + }, + }; + }); it('creates user when submitting form and redirects back', async () => { - const coreStart = coreMock.createStart(); - const history = createMemoryHistory({ initialEntries: ['/create'] }); - const authc = securityMock.createSetup().authc; coreStart.http.post.mockResolvedValue({}); const { findByRole, findByLabelText } = render( @@ -57,11 +71,26 @@ describe('CreateUserPage', () => { }); }); - it('validates form', async () => { - const coreStart = coreMock.createStart(); - const history = createMemoryHistory({ initialEntries: ['/create'] }); - const authc = securityMock.createSetup().authc; + it('redirects back when viewing with readonly privileges', async () => { + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + users: { + save: false, + }, + }; + render( + + + + ); + + await waitFor(() => { + expect(history.location.pathname).toBe('/'); + }); + }); + + it('validates form', async () => { coreStart.http.get.mockResolvedValueOnce([]); coreStart.http.get.mockResolvedValueOnce([ { diff --git a/x-pack/plugins/security/public/management/users/edit_user/create_user_page.tsx b/x-pack/plugins/security/public/management/users/edit_user/create_user_page.tsx index 52b2988ca5f8..d72732cfd99e 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/create_user_page.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/create_user_page.tsx @@ -7,17 +7,25 @@ import { EuiPageHeader, EuiSpacer } from '@elastic/eui'; import type { FunctionComponent } from 'react'; -import React from 'react'; +import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useCapabilities } from '../../../components/use_capabilities'; import { UserForm } from './user_form'; export const CreateUserPage: FunctionComponent = () => { const history = useHistory(); + const readOnly = !useCapabilities('users').save; const backToUsers = () => history.push('/'); + useEffect(() => { + if (readOnly) { + backToUsers(); + } + }, [readOnly]); // eslint-disable-line react-hooks/exhaustive-deps + return ( <> { coreStart.http.post.mockClear(); coreStart.notifications.toasts.addDanger.mockClear(); coreStart.notifications.toasts.addSuccess.mockClear(); + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + users: { + save: true, + }, + }; }); it('warns when viewing deactivated user', async () => { @@ -125,4 +131,29 @@ describe('EditUserPage', () => { await findByText(/Role .deprecated_role. is deprecated. Use .new_role. instead/i); }); + + it('disables form when viewing with readonly privileges', async () => { + coreStart.http.get.mockResolvedValueOnce(userMock); + coreStart.http.get.mockResolvedValueOnce([]); + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + users: { + save: false, + }, + }; + + const { findByRole, findAllByRole } = render( + + + + ); + + await findByRole('button', { name: 'Back to users' }); + + const fields = await findAllByRole('textbox'); + expect(fields.length).toBeGreaterThanOrEqual(1); + fields.forEach((field) => { + expect(field).toHaveProperty('disabled', true); + }); + }); }); diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx index bdbae4dc22a1..9a3c3f8153b8 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx @@ -30,6 +30,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { getUserDisplayName } from '../../../../common/model'; +import { useCapabilities } from '../../../components/use_capabilities'; import { UserAPIClient } from '../user_api_client'; import { isUserDeprecated, isUserReserved } from '../user_utils'; import { ChangePasswordModal } from './change_password_modal'; @@ -57,6 +58,7 @@ export const EditUserPage: FunctionComponent = ({ username }) [services.http] ); const [action, setAction] = useState('none'); + const readOnly = !useCapabilities('users').save; const backToUsers = () => history.push('/'); @@ -155,181 +157,186 @@ export const EditUserPage: FunctionComponent = ({ username }) defaultValues={user} onCancel={backToUsers} onSuccess={backToUsers} + disabled={readOnly} /> - {action === 'changePassword' ? ( - setAction('none')} - onSuccess={() => setAction('none')} - /> - ) : action === 'disableUser' ? ( - setAction('none')} - onSuccess={() => { - setAction('none'); - getUser(); - }} - /> - ) : action === 'enableUser' ? ( - setAction('none')} - onSuccess={() => { - setAction('none'); - getUser(); - }} - /> - ) : action === 'deleteUser' ? ( - setAction('none')} - onSuccess={backToUsers} - /> - ) : undefined} - - - - - - - - - - - - - - - - - - setAction('changePassword')} - size="s" - data-test-subj="editUserChangePasswordButton" - > - - - - - - - - {user.enabled === false ? ( - - - - - - - - - - - - - - setAction('enableUser')} - size="s" - data-test-subj="editUserEnableUserButton" - > - - - - - - ) : ( - - - - - - - - - - - - - - setAction('disableUser')} - size="s" - data-test-subj="editUserDisableUserButton" - > - - - - - - )} - - {!isReservedUser && ( + {readOnly ? undefined : ( <> + {action === 'changePassword' ? ( + setAction('none')} + onSuccess={() => setAction('none')} + /> + ) : action === 'disableUser' ? ( + setAction('none')} + onSuccess={() => { + setAction('none'); + getUser(); + }} + /> + ) : action === 'enableUser' ? ( + setAction('none')} + onSuccess={() => { + setAction('none'); + getUser(); + }} + /> + ) : action === 'deleteUser' ? ( + setAction('none')} + onSuccess={backToUsers} + /> + ) : undefined} + + + setAction('deleteUser')} + onClick={() => setAction('changePassword')} size="s" - color="danger" - data-test-subj="editUserDeleteUserButton" + data-test-subj="editUserChangePasswordButton" > + + + {user.enabled === false ? ( + + + + + + + + + + + + + + setAction('enableUser')} + size="s" + data-test-subj="editUserEnableUserButton" + > + + + + + + ) : ( + + + + + + + + + + + + + + setAction('disableUser')} + size="s" + data-test-subj="editUserDisableUserButton" + > + + + + + + )} + + {!isReservedUser && ( + <> + + + + + + + + + + + + + + + setAction('deleteUser')} + size="s" + color="danger" + data-test-subj="editUserDeleteUserButton" + > + + + + + + + )} )} diff --git a/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx b/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx index 83cf8dae8941..41c29ab77386 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx @@ -56,6 +56,7 @@ export interface UserFormProps { defaultValues?: UserFormValues; onCancel(): void; onSuccess?(): void; + disabled?: boolean; } const defaultDefaultValues: UserFormValues = { @@ -73,6 +74,7 @@ export const UserForm: FunctionComponent = ({ defaultValues = defaultDefaultValues, onSuccess, onCancel, + disabled = false, }) => { const { services } = useKibana(); @@ -269,7 +271,7 @@ export const UserForm: FunctionComponent = ({ value={form.values.username} isLoading={form.isValidating} isInvalid={form.touched.username && !!form.errors.username} - disabled={!isNewUser} + disabled={disabled || !isNewUser} onChange={eventHandlers.onChange} onBlur={eventHandlers.onBlur} /> @@ -291,6 +293,7 @@ export const UserForm: FunctionComponent = ({ isInvalid={form.touched.full_name && !!form.errors.full_name} onChange={eventHandlers.onChange} onBlur={eventHandlers.onBlur} + disabled={disabled} /> = ({ isInvalid={form.touched.email && !!form.errors.email} onChange={eventHandlers.onChange} onBlur={eventHandlers.onBlur} + disabled={disabled} /> @@ -349,6 +353,7 @@ export const UserForm: FunctionComponent = ({ autoComplete="new-password" onChange={eventHandlers.onChange} onBlur={eventHandlers.onBlur} + disabled={disabled} /> = ({ autoComplete="new-password" onChange={eventHandlers.onChange} onBlur={eventHandlers.onBlur} + disabled={disabled} /> @@ -423,12 +429,12 @@ export const UserForm: FunctionComponent = ({ selectedRoleNames={selectedRoleNames} onChange={(value) => form.setValue('roles', value)} isLoading={rolesState.loading} - isDisabled={isReservedUser} + isDisabled={disabled || isReservedUser} /> - {isReservedUser ? ( + {disabled || isReservedUser ? ( diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx index 99de5391c041..3c133b3628b4 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx @@ -31,7 +31,7 @@ describe('UsersGridPage', () => { coreStart = coreMock.createStart(); }); - it('renders the list of users', async () => { + it('renders the list of users and create button', async () => { const apiClientMock = userAPIClientMock.create(); apiClientMock.getUsers.mockImplementation(() => { return Promise.resolve([ @@ -71,6 +71,7 @@ describe('UsersGridPage', () => { expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1); expect(wrapper.find('EuiTableRow')).toHaveLength(2); expect(findTestSubject(wrapper, 'userDisabled')).toHaveLength(0); + expect(findTestSubject(wrapper, 'createUserButton')).toHaveLength(1); }); it('renders the loading indication on the table when fetching user with data', async () => { @@ -375,6 +376,46 @@ describe('UsersGridPage', () => { }, ]); }); + + it('hides controls when `readOnly` is enabled', async () => { + const apiClientMock = userAPIClientMock.create(); + apiClientMock.getUsers.mockImplementation(() => { + return Promise.resolve([ + { + username: 'foo', + email: 'foo@bar.net', + full_name: 'foo bar', + roles: ['kibana_user'], + enabled: true, + }, + { + username: 'reserved', + email: 'reserved@bar.net', + full_name: '', + roles: ['superuser'], + enabled: true, + metadata: { + _reserved: true, + }, + }, + ]); + }); + + const wrapper = mountWithIntl( + + ); + + await waitForRender(wrapper); + + expect(findTestSubject(wrapper, 'createUserButton')).toHaveLength(0); + }); }); async function waitForRender(wrapper: ReactWrapper) { diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx index 748cd8527742..0649de83749c 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx @@ -40,6 +40,7 @@ interface Props { notifications: NotificationsStart; history: ScopedHistory; navigateToApp: ApplicationStart['navigateToApp']; + readOnly?: boolean; } interface State { @@ -55,6 +56,10 @@ interface State { } export class UsersGridPage extends Component { + static defaultProps: Partial = { + readOnly: false, + }; + constructor(props: Props) { super(props); this.state = { @@ -69,7 +74,6 @@ export class UsersGridPage extends Component { isTableLoading: false, }; } - public componentDidMount() { this.loadUsersAndRoles(); } @@ -231,19 +235,23 @@ export class UsersGridPage extends Component { defaultMessage="Users" /> } - rightSideItems={[ - - - , - ]} + rightSideItems={ + this.props.readOnly + ? undefined + : [ + + + , + ] + } /> @@ -266,7 +274,7 @@ export class UsersGridPage extends Component { })} rowHeader="username" columns={columns} - selection={selectionConfig} + selection={this.props.readOnly ? undefined : selectionConfig} pagination={pagination} items={this.state.visibleUsers} loading={this.state.isTableLoading} diff --git a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx index ec7b1d19226b..dd5495cd8bd1 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx @@ -22,9 +22,14 @@ describe('usersManagementApp', () => { const coreStartMock = coreMock.createStart(); getStartServices.mockResolvedValue([coreStartMock, {}, {}]); const { authc } = securityMock.createSetup(); - const setBreadcrumbs = jest.fn(); const history = scopedHistoryMock.create({ pathname: '/create' }); + coreStartMock.application.capabilities = { + ...coreStartMock.application.capabilities, + users: { + save: true, + }, + }; let unmount: Unmount = noop; await act(async () => { diff --git a/x-pack/plugins/security/public/management/users/users_management_app.tsx b/x-pack/plugins/security/public/management/users/users_management_app.tsx index de7a4110a3f3..a8a13bb72dc6 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.tsx @@ -28,6 +28,7 @@ import { } from '../../components/breadcrumb'; import { AuthenticationProvider } from '../../components/use_current_user'; import type { PluginStartDependencies } from '../../plugin'; +import { ReadonlyBadge } from '../badges/readonly_badge'; import { tryDecodeURIComponent } from '../url_utils'; interface CreateParams { @@ -72,6 +73,12 @@ export const usersManagementApp = Object.freeze({ authc={authc} onChange={createBreadcrumbsChangeHandler(coreStart.chrome, setBreadcrumbs)} > + diff --git a/x-pack/plugins/security/server/features/security_features.ts b/x-pack/plugins/security/server/features/security_features.ts index b741d8091518..396f2d1640e1 100644 --- a/x-pack/plugins/security/server/features/security_features.ts +++ b/x-pack/plugins/security/server/features/security_features.ts @@ -16,6 +16,10 @@ const userManagementFeature: ElasticsearchFeatureConfig = { privileges: [ { requiredClusterPrivileges: ['manage_security'], + ui: ['save'], + }, + { + requiredClusterPrivileges: ['read_security'], ui: [], }, ], From f3854f58f1bd1c6d355e8d8b4e05655a876ae849 Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Wed, 19 Oct 2022 20:47:25 +0100 Subject: [PATCH 10/43] Expose suggest user profile hint param and fix data prefix (#143000) * Expose suggest user profile hint param and fix data prefix * [CI] Auto-commit changed files from 'node scripts/build_plugin_list_docs' * Fix types Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../user_profile/user_profile_service.test.ts | 81 +++++++++++++++---- .../user_profile/user_profile_service.ts | 41 ++++++++-- .../server/init_routes.ts | 8 +- .../tests/user_profiles/suggest.ts | 32 +++++++- 4 files changed, 140 insertions(+), 22 deletions(-) diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts index a268c2e0c8f2..76d90f23a2e8 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -25,7 +25,7 @@ import { userProfileMock } from '../../common/model/user_profile.mock'; import { authorizationMock } from '../authorization/index.mock'; import { securityMock } from '../mocks'; import { sessionMock } from '../session_management/session.mock'; -import { UserProfileService } from './user_profile_service'; +import { prefixCommaSeparatedValues, UserProfileService } from './user_profile_service'; const logger = loggingSystemMock.createLogger(); describe('UserProfileService', () => { @@ -245,7 +245,7 @@ describe('UserProfileService', () => { } as unknown as SecurityGetUserProfileResponse); const startContract = userProfileService.start(mockStartParams); - await expect(startContract.getCurrent({ request: mockRequest, dataPath: '*' })).resolves + await expect(startContract.getCurrent({ request: mockRequest, dataPath: 'one,two' })).resolves .toMatchInlineSnapshot(` Object { "data": Object { @@ -277,7 +277,7 @@ describe('UserProfileService', () => { mockStartParams.clusterClient.asInternalUser.security.getUserProfile ).toHaveBeenCalledWith({ uid: 'UID', - data: 'kibana.*', + data: 'kibana.one,kibana.two', }); }); }); @@ -556,8 +556,8 @@ describe('UserProfileService', () => { } as unknown as SecurityGetUserProfileResponse); const startContract = userProfileService.start(mockStartParams); - await expect(startContract.bulkGet({ uids: new Set(['UID-1']), dataPath: '*' })).resolves - .toMatchInlineSnapshot(` + await expect(startContract.bulkGet({ uids: new Set(['UID-1']), dataPath: 'one,two' })) + .resolves.toMatchInlineSnapshot(` Array [ Object { "data": Object { @@ -580,7 +580,7 @@ describe('UserProfileService', () => { mockStartParams.clusterClient.asInternalUser.security.getUserProfile ).toHaveBeenCalledWith({ uid: 'UID-1', - data: 'kibana.*', + data: 'kibana.one,kibana.two', }); }); @@ -683,7 +683,7 @@ describe('UserProfileService', () => { } as unknown as SecuritySuggestUserProfilesResponse); const startContract = userProfileService.start(mockStartParams); - await expect(startContract.suggest({ name: 'some', dataPath: '*' })).resolves + await expect(startContract.suggest({ name: 'some', dataPath: 'one,two' })).resolves .toMatchInlineSnapshot(` Array [ Object { @@ -708,7 +708,44 @@ describe('UserProfileService', () => { ).toHaveBeenCalledWith({ name: 'some', size: 10, - data: 'kibana.*', + data: 'kibana.one,kibana.two', + }); + expect(mockAuthz.checkUserProfilesPrivileges).not.toHaveBeenCalled(); + }); + + it('should request data if uid hints are specified', async () => { + mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles.mockResolvedValue({ + profiles: [ + userProfileMock.createWithSecurity({ + uid: 'UID-1', + }), + ], + } as unknown as SecuritySuggestUserProfilesResponse); + + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.suggest({ hint: { uids: ['UID-1'] } })).resolves + .toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "UID-1", + "user": Object { + "email": "some@email", + "full_name": undefined, + "username": "some-username", + }, + }, + ] + `); + expect( + mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles + ).toHaveBeenCalledTimes(1); + expect( + mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles + ).toHaveBeenCalledWith({ + size: 10, + hint: { uids: ['UID-1'] }, }); expect(mockAuthz.checkUserProfilesPrivileges).not.toHaveBeenCalled(); }); @@ -788,7 +825,7 @@ describe('UserProfileService', () => { startContract.suggest({ name: 'some', size: 3, - dataPath: '*', + dataPath: 'one,two', requiredPrivileges: { spaceId: 'some-space', privileges: { kibana: ['privilege-1', 'privilege-2'] }, @@ -842,7 +879,7 @@ describe('UserProfileService', () => { ).toHaveBeenCalledWith({ name: 'some', size: 10, - data: 'kibana.*', + data: 'kibana.one,kibana.two', }); expect(mockAuthz.checkUserProfilesPrivileges).toHaveBeenCalledTimes(1); @@ -891,7 +928,7 @@ describe('UserProfileService', () => { startContract.suggest({ name: 'some', size: 11, - dataPath: '*', + dataPath: 'one,two', requiredPrivileges: { spaceId: 'some-space', privileges: { kibana: ['privilege-1', 'privilege-2'] }, @@ -933,7 +970,7 @@ describe('UserProfileService', () => { ).toHaveBeenCalledWith({ name: 'some', size: 22, - data: 'kibana.*', + data: 'kibana.one,kibana.two', }); expect(mockAuthz.checkUserProfilesPrivileges).toHaveBeenCalledTimes(3); @@ -992,7 +1029,7 @@ describe('UserProfileService', () => { startContract.suggest({ name: 'some', size: 2, - dataPath: '*', + dataPath: 'one,two', requiredPrivileges: { spaceId: 'some-space', privileges: { kibana: ['privilege-1', 'privilege-2'] }, @@ -1034,7 +1071,7 @@ describe('UserProfileService', () => { ).toHaveBeenCalledWith({ name: 'some', size: 10, - data: 'kibana.*', + data: 'kibana.one,kibana.two', }); expect(mockAuthz.checkUserProfilesPrivileges).toHaveBeenCalledTimes(1); @@ -1049,3 +1086,19 @@ describe('UserProfileService', () => { }); }); }); + +describe('prefixCommaSeparatedValues', () => { + it('should prefix each value', () => { + expect(prefixCommaSeparatedValues('one,two,three', '_')).toBe('_.one,_.two,_.three'); + }); + + it('should trim whitespace', () => { + expect(prefixCommaSeparatedValues('one , two, three ', '_')).toBe('_.one,_.two,_.three'); + }); + + it('should ignore empty values', () => { + expect(prefixCommaSeparatedValues('', '_')).toBe(''); + expect(prefixCommaSeparatedValues(' ', '_')).toBe(''); + expect(prefixCommaSeparatedValues(' ,, ', '_')).toBe(''); + }); +}); diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts index 76948a1af9a7..8babcaae4f90 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -155,7 +155,19 @@ export interface UserProfileSuggestParams { * Query string used to match name-related fields in user profiles. The following fields are treated as * name-related: username, full_name and email. */ - name: string; + name?: string; + + /** + * Extra search criteria to improve relevance of the suggestion result. A profile matching the + * specified hint is ranked higher in the response. But not-matching the hint does not exclude a + * profile from the response as long as it matches the `name` field query. + */ + hint?: { + /** + * A list of Profile UIDs to match against. + */ + uids: string[]; + }; /** * Desired number of suggestion to return. The default value is 10. @@ -322,7 +334,7 @@ export class UserProfileService { // @ts-expect-error Invalid response format. body = (await clusterClient.asInternalUser.security.getUserProfile({ uid: userSession.userProfileId, - data: dataPath ? `${KIBANA_DATA_ROOT}.${dataPath}` : undefined, + data: dataPath ? prefixCommaSeparatedValues(dataPath, KIBANA_DATA_ROOT) : undefined, })) as { profiles: SecurityUserProfileWithMetadata[] }; } catch (error) { this.logger.error( @@ -360,7 +372,7 @@ export class UserProfileService { // @ts-expect-error Invalid response format. const body = (await clusterClient.asInternalUser.security.getUserProfile({ uid: [...uids].join(','), - data: dataPath ? `${KIBANA_DATA_ROOT}.${dataPath}` : undefined, + data: dataPath ? prefixCommaSeparatedValues(dataPath, KIBANA_DATA_ROOT) : undefined, })) as { profiles: SecurityUserProfileWithMetadata[] }; return body.profiles.map((rawUserProfile) => parseUserProfile(rawUserProfile)); @@ -402,7 +414,7 @@ export class UserProfileService { throw Error("Current license doesn't support user profile collaboration APIs."); } - const { name, size = DEFAULT_SUGGESTIONS_COUNT, dataPath, requiredPrivileges } = params; + const { name, hint, size = DEFAULT_SUGGESTIONS_COUNT, dataPath, requiredPrivileges } = params; if (size > MAX_SUGGESTIONS_COUNT) { throw Error( `Can return up to ${MAX_SUGGESTIONS_COUNT} suggestions, but ${size} suggestions were requested.` @@ -422,9 +434,10 @@ export class UserProfileService { const body = await clusterClient.asInternalUser.security.suggestUserProfiles({ name, size: numberOfResultsToRequest, + hint, // If fetching data turns out to be a performance bottleneck, we can try to fetch data // only for the profiles that pass privileges check as a separate bulkGet request. - data: dataPath ? `${KIBANA_DATA_ROOT}.${dataPath}` : undefined, + data: dataPath ? prefixCommaSeparatedValues(dataPath, KIBANA_DATA_ROOT) : undefined, }); const filteredProfiles = @@ -504,3 +517,21 @@ export class UserProfileService { return filteredProfiles; } } + +/** + * Returns string of comma separated values prefixed with `prefix`. + * @param str String of comma separated values + * @param prefix Prefix to use prepend to each value + */ +export function prefixCommaSeparatedValues(str: string, prefix: string) { + return str + .split(',') + .reduce((accumulator, value) => { + const trimmedValue = value.trim(); + if (trimmedValue) { + accumulator.push(`${prefix}.${trimmedValue}`); + } + return accumulator; + }, []) + .join(','); +} diff --git a/x-pack/test/security_api_integration/fixtures/user_profiles/user_profiles_consumer/server/init_routes.ts b/x-pack/test/security_api_integration/fixtures/user_profiles/user_profiles_consumer/server/init_routes.ts index aa45cb7e3bb4..091e50ff1735 100644 --- a/x-pack/test/security_api_integration/fixtures/user_profiles/user_profiles_consumer/server/init_routes.ts +++ b/x-pack/test/security_api_integration/fixtures/user_profiles/user_profiles_consumer/server/init_routes.ts @@ -16,8 +16,13 @@ export function initRoutes(core: CoreSetup) { path: '/internal/user_profiles_consumer/_suggest', validate: { body: schema.object({ - name: schema.string(), + name: schema.maybe(schema.string()), dataPath: schema.maybe(schema.string()), + hint: schema.maybe( + schema.object({ + uids: schema.arrayOf(schema.string()), + }) + ), size: schema.maybe(schema.number()), requiredAppPrivileges: schema.maybe(schema.arrayOf(schema.string())), }), @@ -28,6 +33,7 @@ export function initRoutes(core: CoreSetup) { const profiles = await pluginDeps.security.userProfiles.suggest({ name: request.body.name, dataPath: request.body.dataPath, + hint: request.body.hint, size: request.body.size, requiredPrivileges: request.body.requiredAppPrivileges ? { diff --git a/x-pack/test/security_api_integration/tests/user_profiles/suggest.ts b/x-pack/test/security_api_integration/tests/user_profiles/suggest.ts index 1e45f0edacf3..cf58f1b35d3b 100644 --- a/x-pack/test/security_api_integration/tests/user_profiles/suggest.ts +++ b/x-pack/test/security_api_integration/tests/user_profiles/suggest.ts @@ -305,7 +305,7 @@ export default function ({ getService }: FtrProviderContext) { .post('/internal/security/user_profile/_data') .set('kbn-xsrf', 'xxx') .set('Cookie', usersSessions.get('user_one')!.cookie.cookieString()) - .send({ some: 'data', some_nested: { data: 'nested_data' } }) + .send({ some: 'data', some_more: 'data', some_nested: { data: 'nested_data' } }) .expect(200); // 2. Data is not returned by default @@ -334,7 +334,7 @@ export default function ({ getService }: FtrProviderContext) { suggestions = await supertest .post('/internal/user_profiles_consumer/_suggest') .set('kbn-xsrf', 'xxx') - .send({ name: 'one', requiredAppPrivileges: ['discover'], dataPath: 'some' }) + .send({ name: 'one', requiredAppPrivileges: ['discover'], dataPath: 'some,some_more' }) .expect(200); expect(suggestions.body).to.have.length(1); expectSnapshot( @@ -344,6 +344,7 @@ export default function ({ getService }: FtrProviderContext) { Object { "data": Object { "some": "data", + "some_more": "data", }, "user": Object { "email": "one@elastic.co", @@ -368,6 +369,7 @@ export default function ({ getService }: FtrProviderContext) { Object { "data": Object { "some": "data", + "some_more": "data", "some_nested": Object { "data": "nested_data", }, @@ -381,5 +383,31 @@ export default function ({ getService }: FtrProviderContext) { ] `); }); + + it('can get suggestions with hints', async () => { + const profile = await supertestWithoutAuth + .get('/internal/security/user_profile') + .set('kbn-xsrf', 'xxx') + .set('Cookie', usersSessions.get('user_three')!.cookie.cookieString()) + .expect(200); + + expect(profile.body.uid).not.to.be.empty(); + + const suggestions = await supertest + .post('/internal/user_profiles_consumer/_suggest') + .set('kbn-xsrf', 'xxx') + .send({ hint: { uids: [profile.body.uid] } }) + .expect(200); + + // `user_three` should be first in list + expect(suggestions.body.length).to.be.above(0); + expectSnapshot(suggestions.body[0].user).toMatchInline(` + Object { + "email": "three@elastic.co", + "full_name": "THREE", + "username": "user_three", + } + `); + }); }); } From fcea2e7d4d5b98b77634e93dbcdf803c83a92404 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 19 Oct 2022 13:22:54 -0700 Subject: [PATCH 11/43] [ci/longFtrGroup] reduce annotation to warning, tell people not to worry (#143678) --- .../ci-stats/pick_test_group_run_order.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts b/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts index c326e979b93d..b7c223b3ca59 100644 --- a/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts +++ b/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts @@ -59,11 +59,15 @@ function getRunGroups(bk: BuildkiteClient, allTypes: RunGroup[], typeName: strin if (tooLongs.length > 0) { bk.setAnnotation( `test-group-too-long:${typeName}`, - 'error', + 'warning', [ tooLongs.length === 1 - ? `The following "${typeName}" config has a duration that exceeds the maximum amount of time desired for a single CI job. Please split it up.` - : `The following "${typeName}" configs have durations that exceed the maximum amount of time desired for a single CI job. Please split them up.`, + ? `The following "${typeName}" config has a duration that exceeds the maximum amount of time desired for a single CI job. ` + + `This is not an error, and if you don't own this config then you can ignore this warning. ` + + `If you own this config please split it up ASAP and ask Operations if you have questions about how to do that.` + : `The following "${typeName}" configs have durations that exceed the maximum amount of time desired for a single CI job. ` + + `This is not an error, and if you don't own any of these configs then you can ignore this warning.` + + `If you own any of these configs please split them up ASAP and ask Operations if you have questions about how to do that.`, '', ...tooLongs.map(({ config, durationMin }) => ` - ${config}: ${durationMin} minutes`), ].join('\n') From c8a0376eaffa608286645f9964e932f2cfb53318 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Wed, 19 Oct 2022 16:27:12 -0400 Subject: [PATCH 12/43] [Embeddables as Building Blocks] Controls API (#140739) Added control group API and renderer component --- .../control_group/control_group_renderer.tsx | 84 +++++++++++++++++++ .../control_group/control_group_strings.ts | 4 - .../control_group/editor/control_editor.tsx | 55 +++--------- .../editor/data_control_editor_tools.ts | 70 ++++++++++++++++ .../embeddable/control_group_container.tsx | 42 +++++++++- .../control_group_container_factory.ts | 6 +- .../controls/public/control_group/index.ts | 5 ++ src/plugins/controls/public/index.ts | 1 + .../services/embeddable/embeddable.story.ts | 18 ++++ .../services/embeddable/embeddable_service.ts | 22 +++++ .../public/services/embeddable/types.ts | 13 +++ .../public/services/plugin_services.story.ts | 2 + .../public/services/plugin_services.stub.ts | 2 + .../public/services/plugin_services.ts | 2 + src/plugins/controls/public/services/types.ts | 2 + 15 files changed, 274 insertions(+), 54 deletions(-) create mode 100644 src/plugins/controls/public/control_group/control_group_renderer.tsx create mode 100644 src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts create mode 100644 src/plugins/controls/public/services/embeddable/embeddable.story.ts create mode 100644 src/plugins/controls/public/services/embeddable/embeddable_service.ts create mode 100644 src/plugins/controls/public/services/embeddable/types.ts diff --git a/src/plugins/controls/public/control_group/control_group_renderer.tsx b/src/plugins/controls/public/control_group/control_group_renderer.tsx new file mode 100644 index 000000000000..a1560a02568c --- /dev/null +++ b/src/plugins/controls/public/control_group/control_group_renderer.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import uuid from 'uuid'; +import useLifecycles from 'react-use/lib/useLifecycles'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { IEmbeddable } from '@kbn/embeddable-plugin/public'; + +import { pluginServices } from '../services'; +import { getDefaultControlGroupInput } from '../../common'; +import { ControlGroupInput, ControlGroupOutput, CONTROL_GROUP_TYPE } from './types'; +import { ControlGroupContainer } from './embeddable/control_group_container'; + +export interface ControlGroupRendererProps { + input?: Partial>; + onEmbeddableLoad: (controlGroupContainer: ControlGroupContainer) => void; +} + +export const ControlGroupRenderer = ({ input, onEmbeddableLoad }: ControlGroupRendererProps) => { + const controlsRoot = useRef(null); + const [controlGroupContainer, setControlGroupContainer] = useState(); + + const id = useMemo(() => uuid.v4(), []); + + /** + * Use Lifecycles to load initial control group container + */ + useLifecycles( + () => { + const { embeddable } = pluginServices.getServices(); + + (async () => { + const container = (await embeddable + .getEmbeddableFactory< + ControlGroupInput, + ControlGroupOutput, + IEmbeddable + >(CONTROL_GROUP_TYPE) + ?.create({ id, ...getDefaultControlGroupInput(), ...input })) as ControlGroupContainer; + + if (controlsRoot.current) { + container.render(controlsRoot.current); + } + setControlGroupContainer(container); + onEmbeddableLoad(container); + })(); + }, + () => { + controlGroupContainer?.destroy(); + } + ); + + /** + * Update embeddable input when props input changes + */ + useEffect(() => { + let updateCanceled = false; + (async () => { + // check if applying input from props would result in any changes to the embeddable input + const isInputEqual = await controlGroupContainer?.getExplicitInputIsEqual({ + ...controlGroupContainer?.getInput(), + ...input, + }); + if (!controlGroupContainer || isInputEqual || updateCanceled) return; + controlGroupContainer.updateInput({ ...input }); + })(); + + return () => { + updateCanceled = true; + }; + }, [controlGroupContainer, input]); + + return
; +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default ControlGroupRenderer; diff --git a/src/plugins/controls/public/control_group/control_group_strings.ts b/src/plugins/controls/public/control_group/control_group_strings.ts index 238a3b73f757..381321c876c3 100644 --- a/src/plugins/controls/public/control_group/control_group_strings.ts +++ b/src/plugins/controls/public/control_group/control_group_strings.ts @@ -9,10 +9,6 @@ import { i18n } from '@kbn/i18n'; export const ControlGroupStrings = { - getEmbeddableTitle: () => - i18n.translate('controls.controlGroup.title', { - defaultMessage: 'Control group', - }), getControlButtonTitle: () => i18n.translate('controls.controlGroup.toolbarButtonTitle', { defaultMessage: 'Controls', diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx index fdc54dd3cad6..1e9c42ff420d 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx @@ -14,7 +14,7 @@ * Side Public License, v 1. */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import useMount from 'react-use/lib/useMount'; import { @@ -36,7 +36,6 @@ import { EuiTextColor, } from '@elastic/eui'; import { DataViewListItem, DataView, DataViewField } from '@kbn/data-views-plugin/common'; -import { IFieldSubTypeMulti } from '@kbn/es-query'; import { LazyDataViewPicker, LazyFieldPicker, @@ -53,6 +52,7 @@ import { } from '../../types'; import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; import { pluginServices } from '../../services'; +import { loadFieldRegistryFromDataViewId } from './data_control_editor_tools'; interface EditControlProps { embeddable?: ControlEmbeddable; isCreate: boolean; @@ -97,7 +97,7 @@ export const ControlEditor = ({ }: EditControlProps) => { const { dataViews: { getIdsWithTitle, getDefaultId, get }, - controls: { getControlTypes, getControlFactory }, + controls: { getControlFactory }, } = pluginServices.getServices(); const [state, setState] = useState({ dataViewListItems: [], @@ -112,49 +112,14 @@ export const ControlEditor = ({ embeddable ? embeddable.getInput().fieldName : undefined ); - const doubleLinkFields = (dataView: DataView) => { - // double link the parent-child relationship specifically for case-sensitivity support for options lists - const fieldRegistry: DataControlFieldRegistry = {}; - - for (const field of dataView.fields.getAll()) { - if (!fieldRegistry[field.name]) { - fieldRegistry[field.name] = { field, compatibleControlTypes: [] }; - } - const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; - if (parentFieldName) { - fieldRegistry[field.name].parentFieldName = parentFieldName; - - const parentField = dataView.getFieldByName(parentFieldName); - if (!fieldRegistry[parentFieldName] && parentField) { - fieldRegistry[parentFieldName] = { field: parentField, compatibleControlTypes: [] }; - } - fieldRegistry[parentFieldName].childFieldName = field.name; - } - } - return fieldRegistry; - }; - - const fieldRegistry = useMemo(() => { - if (!state.selectedDataView) return; - const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(state.selectedDataView); - - const controlFactories = getControlTypes().map( - (controlType) => getControlFactory(controlType) as IEditableControlFactory - ); - state.selectedDataView.fields.map((dataViewField) => { - for (const factory of controlFactories) { - if (factory.isFieldCompatible) { - factory.isFieldCompatible(newFieldRegistry[dataViewField.name]); - } - } - - if (newFieldRegistry[dataViewField.name]?.compatibleControlTypes.length === 0) { - delete newFieldRegistry[dataViewField.name]; + const [fieldRegistry, setFieldRegistry] = useState(); + useEffect(() => { + (async () => { + if (state.selectedDataView?.id) { + setFieldRegistry(await loadFieldRegistryFromDataViewId(state.selectedDataView.id)); } - }); - - return newFieldRegistry; - }, [state.selectedDataView, getControlFactory, getControlTypes]); + })(); + }, [state.selectedDataView]); useMount(() => { let mounted = true; diff --git a/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts b/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts new file mode 100644 index 000000000000..cb0d1db5f4a8 --- /dev/null +++ b/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IFieldSubTypeMulti } from '@kbn/es-query'; +import { DataView } from '@kbn/data-views-plugin/common'; + +import { pluginServices } from '../../services'; +import { DataControlFieldRegistry, IEditableControlFactory } from '../../types'; + +const dataControlFieldRegistryCache: { [key: string]: DataControlFieldRegistry } = {}; + +const doubleLinkFields = (dataView: DataView) => { + // double link the parent-child relationship specifically for case-sensitivity support for options lists + const fieldRegistry: DataControlFieldRegistry = {}; + + for (const field of dataView.fields.getAll()) { + if (!fieldRegistry[field.name]) { + fieldRegistry[field.name] = { field, compatibleControlTypes: [] }; + } + const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; + if (parentFieldName) { + fieldRegistry[field.name].parentFieldName = parentFieldName; + + const parentField = dataView.getFieldByName(parentFieldName); + if (!fieldRegistry[parentFieldName] && parentField) { + fieldRegistry[parentFieldName] = { field: parentField, compatibleControlTypes: [] }; + } + fieldRegistry[parentFieldName].childFieldName = field.name; + } + } + return fieldRegistry; +}; + +export const loadFieldRegistryFromDataViewId = async ( + dataViewId: string +): Promise => { + if (dataControlFieldRegistryCache[dataViewId]) { + return dataControlFieldRegistryCache[dataViewId]; + } + const { + dataViews, + controls: { getControlTypes, getControlFactory }, + } = pluginServices.getServices(); + const dataView = await dataViews.get(dataViewId); + + const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(dataView); + + const controlFactories = getControlTypes().map( + (controlType) => getControlFactory(controlType) as IEditableControlFactory + ); + dataView.fields.map((dataViewField) => { + for (const factory of controlFactories) { + if (factory.isFieldCompatible) { + factory.isFieldCompatible(newFieldRegistry[dataViewField.name]); + } + } + + if (newFieldRegistry[dataViewField.name]?.compatibleControlTypes.length === 0) { + delete newFieldRegistry[dataViewField.name]; + } + }); + dataControlFieldRegistryCache[dataViewId] = newFieldRegistry; + + return newFieldRegistry; +}; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index a1d8bc06f822..35c251a179d0 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -9,7 +9,7 @@ import { skip, debounceTime, distinctUntilChanged } from 'rxjs/operators'; import React from 'react'; import ReactDOM from 'react-dom'; -import { Filter, uniqFilters } from '@kbn/es-query'; +import { compareFilters, COMPARE_ALL_OPTIONS, Filter, uniqFilters } from '@kbn/es-query'; import { BehaviorSubject, merge, Subject, Subscription } from 'rxjs'; import { EuiContextMenuPanel } from '@elastic/eui'; @@ -39,10 +39,11 @@ import { ControlGroupStrings } from '../control_group_strings'; import { EditControlGroup } from '../editor/edit_control_group'; import { ControlGroup } from '../component/control_group_component'; import { controlGroupReducers } from '../state/control_group_reducers'; -import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; +import { ControlEmbeddable, ControlInput, ControlOutput, DataControlInput } from '../../types'; import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control'; import { CreateTimeSliderControlButton } from '../editor/create_time_slider_control'; import { TIME_SLIDER_CONTROL } from '../../time_slider'; +import { loadFieldRegistryFromDataViewId } from '../editor/data_control_editor_tools'; let flyoutRef: OverlayRef | undefined; export const setFlyoutRef = (newRef: OverlayRef | undefined) => { @@ -70,6 +71,9 @@ export class ControlGroupContainer extends Container< typeof controlGroupReducers >; + public onFiltersPublished$: Subject; + public onControlRemoved$: Subject; + public setLastUsedDataViewId = (lastUsedDataViewId: string) => { this.lastUsedDataViewId = lastUsedDataViewId; }; @@ -87,6 +91,27 @@ export class ControlGroupContainer extends Container< flyoutRef = undefined; } + public async addDataControlFromField({ + uuid, + dataViewId, + fieldName, + title, + }: { + uuid?: string; + dataViewId: string; + fieldName: string; + title?: string; + }) { + const fieldRegistry = await loadFieldRegistryFromDataViewId(dataViewId); + const field = fieldRegistry[fieldName]; + return this.addNewEmbeddable(field.compatibleControlTypes[0], { + id: uuid, + dataViewId, + fieldName, + title: title ?? fieldName, + } as DataControlInput); + } + /** * Returns a button that allows controls to be created externally using the embeddable * @param buttonType Controls the button styling @@ -185,6 +210,8 @@ export class ControlGroupContainer extends Container< ); this.recalculateFilters$ = new Subject(); + this.onFiltersPublished$ = new Subject(); + this.onControlRemoved$ = new Subject(); // build redux embeddable tools this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools< @@ -249,6 +276,10 @@ export class ControlGroupContainer extends Container< return Object.keys(this.getInput().panels).length; }; + public updateFilterContext = (filters: Filter[]) => { + this.updateInput({ filters }); + }; + private recalculateFilters = () => { const allFilters: Filter[] = []; let timeslice; @@ -259,7 +290,11 @@ export class ControlGroupContainer extends Container< timeslice = childOutput.timeslice; } }); - this.updateOutput({ filters: uniqFilters(allFilters), timeslice }); + // if filters are different, publish them + if (!compareFilters(this.output.filters ?? [], allFilters ?? [], COMPARE_ALL_OPTIONS)) { + this.updateOutput({ filters: uniqFilters(allFilters), timeslice }); + this.onFiltersPublished$.next(allFilters); + } }; private recalculateDataViews = () => { @@ -304,6 +339,7 @@ export class ControlGroupContainer extends Container< order: currentOrder - 1, }; } + this.onControlRemoved$.next(idToRemove); return newPanels; } diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts index bcbd31955f36..366ad399baee 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts @@ -14,12 +14,12 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import { Container, EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public'; import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public'; import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; import { ControlGroupInput, CONTROL_GROUP_TYPE } from '../types'; -import { ControlGroupStrings } from '../control_group_strings'; import { createControlGroupExtract, createControlGroupInject, @@ -40,7 +40,9 @@ export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition public isEditable = async () => false; public readonly getDisplayName = () => { - return ControlGroupStrings.getEmbeddableTitle(); + return i18n.translate('controls.controlGroup.title', { + defaultMessage: 'Control group', + }); }; public getDefaultInput(): Partial { diff --git a/src/plugins/controls/public/control_group/index.ts b/src/plugins/controls/public/control_group/index.ts index 60050786d7c1..ded1c29934d6 100644 --- a/src/plugins/controls/public/control_group/index.ts +++ b/src/plugins/controls/public/control_group/index.ts @@ -6,8 +6,13 @@ * Side Public License, v 1. */ +import React from 'react'; + export type { ControlGroupContainer } from './embeddable/control_group_container'; export type { ControlGroupInput, ControlGroupOutput } from './types'; export { CONTROL_GROUP_TYPE } from './types'; export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory'; + +export type { ControlGroupRendererProps } from './control_group_renderer'; +export const LazyControlGroupRenderer = React.lazy(() => import('./control_group_renderer')); diff --git a/src/plugins/controls/public/index.ts b/src/plugins/controls/public/index.ts index f55df5fa0f53..ac7a2ab23df8 100644 --- a/src/plugins/controls/public/index.ts +++ b/src/plugins/controls/public/index.ts @@ -51,6 +51,7 @@ export { } from './range_slider'; export { LazyControlsCallout, type CalloutProps } from './controls_callout'; +export { LazyControlGroupRenderer, type ControlGroupRendererProps } from './control_group'; export function plugin() { return new ControlsPlugin(); diff --git a/src/plugins/controls/public/services/embeddable/embeddable.story.ts b/src/plugins/controls/public/services/embeddable/embeddable.story.ts new file mode 100644 index 000000000000..caeb5c0395fa --- /dev/null +++ b/src/plugins/controls/public/services/embeddable/embeddable.story.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginServiceFactory } from '@kbn/presentation-util-plugin/public'; +import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; +import { ControlsEmbeddableService } from './types'; + +export type EmbeddableServiceFactory = PluginServiceFactory; +export const embeddableServiceFactory: EmbeddableServiceFactory = () => { + const { doStart } = embeddablePluginMock.createInstance(); + const start = doStart(); + return { getEmbeddableFactory: start.getEmbeddableFactory }; +}; diff --git a/src/plugins/controls/public/services/embeddable/embeddable_service.ts b/src/plugins/controls/public/services/embeddable/embeddable_service.ts new file mode 100644 index 000000000000..06111098e673 --- /dev/null +++ b/src/plugins/controls/public/services/embeddable/embeddable_service.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public'; +import { ControlsEmbeddableService } from './types'; +import { ControlsPluginStartDeps } from '../../types'; + +export type EmbeddableServiceFactory = KibanaPluginServiceFactory< + ControlsEmbeddableService, + ControlsPluginStartDeps +>; + +export const embeddableServiceFactory: EmbeddableServiceFactory = ({ startPlugins }) => { + return { + getEmbeddableFactory: startPlugins.embeddable.getEmbeddableFactory, + }; +}; diff --git a/src/plugins/controls/public/services/embeddable/types.ts b/src/plugins/controls/public/services/embeddable/types.ts new file mode 100644 index 000000000000..4ddbecd9dbb3 --- /dev/null +++ b/src/plugins/controls/public/services/embeddable/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; + +export interface ControlsEmbeddableService { + getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; +} diff --git a/src/plugins/controls/public/services/plugin_services.story.ts b/src/plugins/controls/public/services/plugin_services.story.ts index b464286c7ca3..64affcbd9903 100644 --- a/src/plugins/controls/public/services/plugin_services.story.ts +++ b/src/plugins/controls/public/services/plugin_services.story.ts @@ -23,6 +23,7 @@ import { themeServiceFactory } from './theme/theme.story'; import { optionsListServiceFactory } from './options_list/options_list.story'; import { controlsServiceFactory } from './controls/controls.story'; +import { embeddableServiceFactory } from './embeddable/embeddable.story'; export const providers: PluginServiceProviders = { dataViews: new PluginServiceProvider(dataViewsServiceFactory), @@ -32,6 +33,7 @@ export const providers: PluginServiceProviders = { overlays: new PluginServiceProvider(overlaysServiceFactory), settings: new PluginServiceProvider(settingsServiceFactory), theme: new PluginServiceProvider(themeServiceFactory), + embeddable: new PluginServiceProvider(embeddableServiceFactory), controls: new PluginServiceProvider(controlsServiceFactory), optionsList: new PluginServiceProvider(optionsListServiceFactory), diff --git a/src/plugins/controls/public/services/plugin_services.stub.ts b/src/plugins/controls/public/services/plugin_services.stub.ts index 08be260ce052..a55d68fa4550 100644 --- a/src/plugins/controls/public/services/plugin_services.stub.ts +++ b/src/plugins/controls/public/services/plugin_services.stub.ts @@ -25,8 +25,10 @@ import { settingsServiceFactory } from './settings/settings.story'; import { unifiedSearchServiceFactory } from './unified_search/unified_search.story'; import { themeServiceFactory } from './theme/theme.story'; import { registry as stubRegistry } from './plugin_services.story'; +import { embeddableServiceFactory } from './embeddable/embeddable.story'; export const providers: PluginServiceProviders = { + embeddable: new PluginServiceProvider(embeddableServiceFactory), controls: new PluginServiceProvider(controlsServiceFactory), data: new PluginServiceProvider(dataServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), diff --git a/src/plugins/controls/public/services/plugin_services.ts b/src/plugins/controls/public/services/plugin_services.ts index f1811063e39a..20950d42df51 100644 --- a/src/plugins/controls/public/services/plugin_services.ts +++ b/src/plugins/controls/public/services/plugin_services.ts @@ -25,6 +25,7 @@ import { optionsListServiceFactory } from './options_list/options_list_service'; import { settingsServiceFactory } from './settings/settings_service'; import { unifiedSearchServiceFactory } from './unified_search/unified_search_service'; import { themeServiceFactory } from './theme/theme_service'; +import { embeddableServiceFactory } from './embeddable/embeddable_service'; export const providers: PluginServiceProviders< ControlsServices, @@ -38,6 +39,7 @@ export const providers: PluginServiceProviders< overlays: new PluginServiceProvider(overlaysServiceFactory), settings: new PluginServiceProvider(settingsServiceFactory), theme: new PluginServiceProvider(themeServiceFactory), + embeddable: new PluginServiceProvider(embeddableServiceFactory), unifiedSearch: new PluginServiceProvider(unifiedSearchServiceFactory), }; diff --git a/src/plugins/controls/public/services/types.ts b/src/plugins/controls/public/services/types.ts index 48b5aef7e29b..f0785f9991be 100644 --- a/src/plugins/controls/public/services/types.ts +++ b/src/plugins/controls/public/services/types.ts @@ -15,11 +15,13 @@ import { ControlsHTTPService } from './http/types'; import { ControlsOptionsListService } from './options_list/types'; import { ControlsSettingsService } from './settings/types'; import { ControlsThemeService } from './theme/types'; +import { ControlsEmbeddableService } from './embeddable/types'; export interface ControlsServices { // dependency services dataViews: ControlsDataViewsService; overlays: ControlsOverlaysService; + embeddable: ControlsEmbeddableService; data: ControlsDataService; unifiedSearch: ControlsUnifiedSearchService; http: ControlsHTTPService; From 9b2708cd45e5233a6a3323cae10fb137b40cbc45 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Wed, 19 Oct 2022 16:37:26 -0400 Subject: [PATCH 13/43] Skipping flaky tests (#143700) --- .../case_view/components/suggest_users_popover.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx index 479b8e39d232..bf22c764290a 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx @@ -36,7 +36,7 @@ describe('SuggestUsersPopover', () => { }; }); - it('calls onUsersChange when 1 user is selected', async () => { + it.skip('calls onUsersChange when 1 user is selected', async () => { const onUsersChange = jest.fn(); const props = { ...defaultProps, onUsersChange }; appMockRender.render(); @@ -182,7 +182,7 @@ describe('SuggestUsersPopover', () => { expect(screen.getByText('1 assigned')).toBeInTheDocument(); }); - it('shows the 1 assigned total after clicking on a user', async () => { + it.skip('shows the 1 assigned total after clicking on a user', async () => { appMockRender.render(); await waitForEuiPopoverOpen(); From 53d07a2283375d061fb34aed82faab7f3d8d808b Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 19 Oct 2022 13:52:33 -0700 Subject: [PATCH 14/43] Puppeteer v18.1 (#143485) * use build options for smaller build * update to puppeteer@18.1.0 * increase timeouts - reporting.queue.timeout: 4m - screenshotting.timeouts.openUrl: 1m - screenshotting.timeouts.waitForElements: 1m - screenshotting.timeouts.renderComplete: 2m * update docs and refs for timeouts * add arm64 info * fix binaryChecksum for linux_x64 * restore comment? * Build script changes from #143022 --- docs/settings/reporting-settings.asciidoc | 16 ++- package.json | 2 +- x-pack/build_chromium/.chromium-commit | 1 - x-pack/build_chromium/README.md | 13 ++- x-pack/build_chromium/build.py | 15 ++- x-pack/build_chromium/init.py | 8 +- x-pack/build_chromium/linux/args.gn | 24 +++- .../config/__snapshots__/schema.test.ts.snap | 4 +- .../plugins/reporting/server/config/schema.ts | 2 +- x-pack/plugins/screenshotting/README.md | 6 +- .../server/browsers/chromium/driver.ts | 28 +++-- .../browsers/chromium/driver_factory/index.ts | 18 ++- .../server/browsers/chromium/paths.ts | 34 +++--- .../server/browsers/download/index.test.ts | 4 +- .../server/config/schema.test.ts | 8 +- .../screenshotting/server/config/schema.ts | 6 +- .../server/screenshots/index.ts | 4 +- yarn.lock | 108 ++++++++---------- 18 files changed, 155 insertions(+), 146 deletions(-) delete mode 100644 x-pack/build_chromium/.chromium-commit diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index afdfbdfd02eb..90ea49fd281f 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -83,7 +83,9 @@ security is enabled, <= 2 else 'undefined' build_path = path.abspath(os.curdir) src_path = path.abspath(path.join(build_path, 'chromium', 'src')) -if arch_name != 'x64' and arch_name != 'arm64': - raise Exception('Unexpected architecture: ' + arch_name + '. `x64` and `arm64` are supported.') - # Configure git print('Configuring git globals...') runcmd('git config --global core.autocrlf false') @@ -29,8 +25,8 @@ print('Updating depot_tools...') original_dir = os.curdir os.chdir(path.join(build_path, 'depot_tools')) - runcmd('git checkout master') - runcmd('git pull origin master') + runcmd('git checkout main') + runcmd('git pull origin main') os.chdir(original_dir) # Fetch the Chromium source code diff --git a/x-pack/build_chromium/linux/args.gn b/x-pack/build_chromium/linux/args.gn index fa6d4e8bcd15..29ec3207c854 100644 --- a/x-pack/build_chromium/linux/args.gn +++ b/x-pack/build_chromium/linux/args.gn @@ -1,9 +1,27 @@ +# Build args reference: https://www.chromium.org/developers/gn-build-configuration/ import("//build/args/headless.gn") -is_debug = false -symbol_level = 0 + +is_official_build = true is_component_build = false +is_debug = false + enable_nacl = false +enable_stripping = true + +chrome_pgo_phase = 0 +dcheck_always_on = false +blink_symbol_level = 0 +symbol_level = 0 +v8_symbol_level = 0 + +enable_ink = false +rtc_build_examples = false +angle_build_tests = false +enable_screen_ai_service = false +enable_vr = false + # Please, consult @elastic/kibana-security before changing/removing this option. use_kerberos = false -# target_cpu is appended before build: "x64" or "arm64" +target_os = "linux" +# target_cpu is added at build timeure a minimal build diff --git a/x-pack/plugins/reporting/server/config/__snapshots__/schema.test.ts.snap b/x-pack/plugins/reporting/server/config/__snapshots__/schema.test.ts.snap index 690259a1c240..057f85d2c81d 100644 --- a/x-pack/plugins/reporting/server/config/__snapshots__/schema.test.ts.snap +++ b/x-pack/plugins/reporting/server/config/__snapshots__/schema.test.ts.snap @@ -36,7 +36,7 @@ Object { "pollEnabled": true, "pollInterval": "PT3S", "pollIntervalErrorMultiplier": 10, - "timeout": "PT2M", + "timeout": "PT4M", }, "roles": Object { "allow": Array [ @@ -82,7 +82,7 @@ Object { "pollEnabled": true, "pollInterval": "PT3S", "pollIntervalErrorMultiplier": 10, - "timeout": "PT2M", + "timeout": "PT4M", }, "roles": Object { "allow": Array [ diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts index c5ec1a7c22c8..fc6ed9ae0739 100644 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -42,7 +42,7 @@ const QueueSchema = schema.object({ }), pollIntervalErrorMultiplier: schema.number({ defaultValue: 10 }), timeout: schema.oneOf([schema.number(), schema.duration()], { - defaultValue: moment.duration({ minutes: 2 }), + defaultValue: moment.duration({ minutes: 4 }), }), }); diff --git a/x-pack/plugins/screenshotting/README.md b/x-pack/plugins/screenshotting/README.md index 3a3ea87448e6..04b7d07f223c 100644 --- a/x-pack/plugins/screenshotting/README.md +++ b/x-pack/plugins/screenshotting/README.md @@ -89,9 +89,9 @@ Option | Required | Default | Description `layout` | no | `{}` | Page layout parameters describing characteristics of the capturing screenshot (e.g., dimensions, zoom, etc.). `request` | no | _none_ | Kibana Request reference to extract headers from. `timeouts` | no | _none_ | Timeouts for each phase of the screenshot. -`timeouts.openUrl` | no | `60000` | The timeout in milliseconds to allow the Chromium browser to wait for the "Loading…" screen to dismiss and find the initial data for the page. If the time is exceeded, a screenshot is captured showing the current page, and the result structure contains an error message. -`timeouts.renderComplete` | no | `30000` | The timeout in milliseconds to allow the Chromium browser to wait for all visualizations to fetch and render the data. If the time is exceeded, a screenshot is captured showing the current page, and the result structure contains an error message. -`timeouts.waitForElements` | no | `30000` | The timeout in milliseconds to allow the Chromium browser to wait for all visualization panels to load on the page. If the time is exceeded, a screenshot is captured showing the current page, and the result structure contains an error message. +`timeouts.openUrl` | no | (kibana.yml setting) | The timeout in milliseconds to allow the Chromium browser to wait for the "Loading…" screen to dismiss and find the initial data for the page. If the time is exceeded, a screenshot is captured showing the current page, and the result structure contains an error message. +`timeouts.renderComplete` | no | (kibana.yml setting) | The timeout in milliseconds to allow the Chromium browser to wait for all visualizations to fetch and render the data. If the time is exceeded, a screenshot is captured showing the current page, and the result structure contains an error message. +`timeouts.waitForElements` | no | (kibana.yml setting) | The timeout in milliseconds to allow the Chromium browser to wait for all visualization panels to load on the page. If the time is exceeded, a screenshot is captured showing the current page, and the result structure contains an error message. `urls` | no | `[]` | The list or URL to take screenshots of. Every item can either be a string or a tuple containing a URL and a context. The contextual data can be gathered using the screenshot mode plugin. Mutually exclusive with the `expression` parameter. #### `diagnose(flags?: string[]): Observable` diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts index 3bdef53bc6a6..3cecb8988907 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts @@ -12,7 +12,7 @@ import { } from '@kbn/screenshot-mode-plugin/server'; import { truncate } from 'lodash'; import open from 'opn'; -import puppeteer, { ElementHandle, EvaluateFn, Page, SerializableOrJSHandle } from 'puppeteer'; +import puppeteer, { ElementHandle, Page, EvaluateFunc } from 'puppeteer'; import { Subject } from 'rxjs'; import { parse as parseUrl } from 'url'; import { getDisallowedOutgoingUrlError } from '.'; @@ -23,6 +23,16 @@ import { allowRequest } from '../network_policy'; import { stripUnsafeHeaders } from './strip_unsafe_headers'; import { getFooterTemplate, getHeaderTemplate } from './templates'; +declare module 'puppeteer' { + interface Page { + _client(): CDPSession; + } + + interface Target { + _targetId: string; + } +} + export type Context = Record; export interface ElementPosition { @@ -51,8 +61,8 @@ interface WaitForSelectorOpts { } interface EvaluateOpts { - fn: EvaluateFn; - args: SerializableOrJSHandle[]; + fn: EvaluateFunc; + args: unknown[]; } interface EvaluateMetaOpts { @@ -312,8 +322,8 @@ export class HeadlessChromiumDriver { args, timeout, }: { - fn: EvaluateFn; - args: SerializableOrJSHandle[]; + fn: EvaluateFunc; + args: unknown[]; timeout: number; }): Promise { await this.page.waitForFunction(fn, { timeout, polling: WAIT_FOR_DELAY_MS }, ...args); @@ -345,8 +355,8 @@ export class HeadlessChromiumDriver { return; } - // FIXME: retrieve the client in open() and pass in the client - const client = this.page.client(); + // FIXME: retrieve the client in open() and pass in the client? + const client = this.page._client(); // We have to reach into the Chrome Devtools Protocol to apply headers as using // puppeteer's API will cause map tile requests to hang indefinitely: @@ -437,12 +447,12 @@ export class HeadlessChromiumDriver { // In order to get the inspector running, we have to know the page's internal ID (again, private) // in order to construct the final debugging URL. + const client = this.page._client(); const target = this.page.target(); - const client = await target.createCDPSession(); + const targetId = target._targetId; await client.send('Debugger.enable'); await client.send('Debugger.pause'); - const targetId = target._targetId; const wsEndpoint = this.page.browser().wsEndpoint(); const { port } = parseUrl(wsEndpoint); diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts index bc001470a8e6..984fa0470d21 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts @@ -293,16 +293,14 @@ export class HeadlessChromiumDriverFactory { */ private async getErrorMessage(message: ConsoleMessage): Promise { for (const arg of message.args()) { - const errorMessage = await arg - .executionContext() - .evaluate((_arg: unknown) => { - /* !! We are now in the browser context !! */ - if (_arg instanceof Error) { - return _arg.message; - } - return undefined; - /* !! End of browser context !! */ - }, arg); + const errorMessage = await arg.evaluate((_arg: unknown) => { + /* !! We are now in the browser context !! */ + if (_arg instanceof Error) { + return _arg.message; + } + return undefined; + /* !! End of browser context !! */ + }, arg); if (errorMessage) { return errorMessage; } diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts index 1a04574155c1..a04308c27dd8 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts @@ -14,11 +14,12 @@ export interface PackageInfo { archiveChecksum: string; binaryChecksum: string; binaryRelativePath: string; - revision: 901912 | 901913; isPreInstalled: boolean; location: 'custom' | 'common'; } +const REVISION = 1036745; + enum BaseUrl { // see https://www.chromium.org/getting-involved/download-chromium common = 'https://commondatastorage.googleapis.com/chromium-browser-snapshots', @@ -44,58 +45,53 @@ export class ChromiumArchivePaths { platform: 'darwin', architecture: 'x64', archiveFilename: 'chrome-mac.zip', - archiveChecksum: '229fd88c73c5878940821875f77578e4', - binaryChecksum: 'b0e5ca009306b14e41527000139852e5', + archiveChecksum: '5afc0d49865d55b69ea1ff65b9cc5794', + binaryChecksum: '4a7a663b2656d66ce975b00a30df3ab4', binaryRelativePath: 'chrome-mac/Chromium.app/Contents/MacOS/Chromium', location: 'common', archivePath: 'Mac', - revision: 901912, isPreInstalled: false, }, { platform: 'darwin', architecture: 'arm64', archiveFilename: 'chrome-mac.zip', - archiveChecksum: 'ecf7aa509c8e2545989ebb9711e35384', - binaryChecksum: 'b5072b06ffd2d2af4fea7012914da09f', + archiveChecksum: '5afc0d49865d55b69ea1ff65b9cc5794', + binaryChecksum: '4a7a663b2656d66ce975b00a30df3ab4', binaryRelativePath: 'chrome-mac/Chromium.app/Contents/MacOS/Chromium', location: 'common', archivePath: 'Mac_Arm', - revision: 901913, // NOTE: 901912 is not available isPreInstalled: false, }, { platform: 'linux', architecture: 'x64', - archiveFilename: 'chromium-70f5d88-locales-linux_x64.zip', - archiveChecksum: '759bda5e5d32533cb136a85e37c0d102', - binaryChecksum: '82e80f9727a88ba3836ce230134bd126', + archiveFilename: 'chromium-749e738-locales-linux_x64.zip', + archiveChecksum: '09ba194e6c720397728fbec3d3895b0b', + binaryChecksum: 'df1c957f41dcca8e33369b1d255406c2', binaryRelativePath: 'headless_shell-linux_x64/headless_shell', location: 'custom', - revision: 901912, isPreInstalled: true, }, { platform: 'linux', architecture: 'arm64', - archiveFilename: 'chromium-70f5d88-locales-linux_arm64.zip', - archiveChecksum: '33613b8dc5212c0457210d5a37ea4b43', - binaryChecksum: '29e943fbee6d87a217abd6cb6747058e', + archiveFilename: 'chromium-749e738-locales-linux_arm64.zip', + archiveChecksum: '1f535b1c2875d471829c6ff128a13262', + binaryChecksum: 'ca6b91d0ba8a65712554572dabc66968', binaryRelativePath: 'headless_shell-linux_arm64/headless_shell', location: 'custom', - revision: 901912, isPreInstalled: true, }, { platform: 'win32', architecture: 'x64', archiveFilename: 'chrome-win.zip', - archiveChecksum: '861bb8b7b8406a6934a87d3cbbce61d9', - binaryChecksum: 'ffa0949471e1b9a57bc8f8633fca9c7b', + archiveChecksum: '42db052673414b89d8cb45657c1a6aeb', + binaryChecksum: '1b6eef775198ffd48fb9669ac0c818f7', binaryRelativePath: path.join('chrome-win', 'chrome.exe'), location: 'common', archivePath: 'Win', - revision: 901912, isPreInstalled: true, }, ]; @@ -118,7 +114,7 @@ export class ChromiumArchivePaths { public getDownloadUrl(p: PackageInfo) { if (isCommonPackage(p)) { - return `${BaseUrl.common}/${p.archivePath}/${p.revision}/${p.archiveFilename}`; + return `${BaseUrl.common}/${p.archivePath}/${REVISION}/${p.archiveFilename}`; } return BaseUrl.custom + '/' + p.archiveFilename; // revision is not used for URL if package is a custom build } diff --git a/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts b/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts index 74a80cf10b58..21f55bfc93e9 100644 --- a/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts +++ b/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts @@ -88,11 +88,11 @@ describe('ensureDownloaded', () => { expect.arrayContaining([ 'chrome-mac.zip', 'chrome-win.zip', - 'chromium-70f5d88-locales-linux_x64.zip', + 'chromium-749e738-locales-linux_x64.zip', ]) ); expect(readdirSync(path.resolve(`${paths.archivesPath}/arm64`))).toEqual( - expect.arrayContaining(['chrome-mac.zip', 'chromium-70f5d88-locales-linux_arm64.zip']) + expect.arrayContaining(['chrome-mac.zip', 'chromium-749e738-locales-linux_arm64.zip']) ); }); diff --git a/x-pack/plugins/screenshotting/server/config/schema.test.ts b/x-pack/plugins/screenshotting/server/config/schema.test.ts index c2febf590624..bb68e8e938ac 100644 --- a/x-pack/plugins/screenshotting/server/config/schema.test.ts +++ b/x-pack/plugins/screenshotting/server/config/schema.test.ts @@ -22,8 +22,8 @@ describe('ConfigSchema', () => { "capture": Object { "timeouts": Object { "openUrl": "PT1M", - "renderComplete": "PT30S", - "waitForElements": "PT30S", + "renderComplete": "PT2M", + "waitForElements": "PT1M", }, "zoom": 2, }, @@ -82,8 +82,8 @@ describe('ConfigSchema', () => { "capture": Object { "timeouts": Object { "openUrl": "PT1M", - "renderComplete": "PT30S", - "waitForElements": "PT30S", + "renderComplete": "PT2M", + "waitForElements": "PT1M", }, "zoom": 2, }, diff --git a/x-pack/plugins/screenshotting/server/config/schema.ts b/x-pack/plugins/screenshotting/server/config/schema.ts index 4900a5c9d775..724f84ea3c81 100644 --- a/x-pack/plugins/screenshotting/server/config/schema.ts +++ b/x-pack/plugins/screenshotting/server/config/schema.ts @@ -50,7 +50,7 @@ export const ConfigSchema = schema.object({ schema.boolean({ defaultValue: false }), schema.maybe(schema.never()) ), - disableSandbox: schema.maybe(schema.boolean()), // default value is dynamic in createConfig$ + disableSandbox: schema.maybe(schema.boolean()), // default value is dynamic in createConfig proxy: schema.object({ enabled: schema.boolean({ defaultValue: false }), server: schema.conditional( @@ -74,10 +74,10 @@ export const ConfigSchema = schema.object({ defaultValue: moment.duration({ minutes: 1 }), }), waitForElements: schema.oneOf([schema.number(), schema.duration()], { - defaultValue: moment.duration({ seconds: 30 }), + defaultValue: moment.duration({ minutes: 1 }), }), renderComplete: schema.oneOf([schema.number(), schema.duration()], { - defaultValue: moment.duration({ seconds: 30 }), + defaultValue: moment.duration({ minutes: 2 }), }), }), zoom: schema.number({ defaultValue: 2 }), diff --git a/x-pack/plugins/screenshotting/server/screenshots/index.ts b/x-pack/plugins/screenshotting/server/screenshots/index.ts index 0c6c6f409f84..c45a0583ffe7 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/index.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/index.ts @@ -201,8 +201,8 @@ export class Screenshots { { timeouts: { openUrl: 60000, - waitForElements: 30000, - renderComplete: 30000, + waitForElements: 60000, + renderComplete: 120000, }, urls: [], } diff --git a/yarn.lock b/yarn.lock index a7a1440720c6..1bf736f1aa39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12381,6 +12381,13 @@ cross-env@^6.0.3: dependencies: cross-spawn "^7.0.0" +cross-fetch@3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" + integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + dependencies: + node-fetch "2.6.7" + cross-spawn@^6.0.0: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -13242,13 +13249,6 @@ debug@4.1.1: dependencies: ms "^2.1.1" -debug@4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" - integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== - dependencies: - ms "2.1.2" - debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" @@ -13594,10 +13594,10 @@ detective@^5.0.2: defined "^1.0.0" minimist "^1.1.1" -devtools-protocol@0.0.901419: - version "0.0.901419" - resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.901419.tgz#79b5459c48fe7e1c5563c02bd72f8fec3e0cebcd" - integrity sha512-4INMPwNm9XRpBukhNbF7OB6fNTTCaI8pzy/fXg0xQzAy5h3zL1P8xT3QazgKqBrb/hAYwIBizqDBZ7GtJE74QQ== +devtools-protocol@0.0.1045489: + version "0.0.1045489" + resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1045489.tgz#f959ad560b05acd72d55644bc3fb8168a83abf28" + integrity sha512-D+PTmWulkuQW4D1NTiCRCFxF7pQPn0hgp4YyX4wAQ6xYXKOadSWPR3ENGDQ47MW/Ewc9v2rpC/UEEGahgBYpSQ== dezalgo@^1.0.0: version "1.0.3" @@ -17060,7 +17060,7 @@ https-proxy-agent@5.0.0: agent-base "6" debug "4" -https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: +https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== @@ -21027,7 +21027,7 @@ node-emoji@^1.10.0: dependencies: lodash.toarray "^4.4.0" -node-fetch@2.6.1, node-fetch@^1.0.1, node-fetch@^2.3.0, node-fetch@^2.6.1, node-fetch@^2.6.7: +node-fetch@2.6.7, node-fetch@^1.0.1, node-fetch@^2.3.0, node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== @@ -22281,13 +22281,6 @@ pixelmatch@^5.3.0: dependencies: pngjs "^6.0.0" -pkg-dir@4.2.0, pkg-dir@^4.1.0, pkg-dir@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" - integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== - dependencies: - find-up "^4.0.0" - pkg-dir@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" @@ -22302,6 +22295,13 @@ pkg-dir@^3.0.0: dependencies: find-up "^3.0.0" +pkg-dir@^4.1.0, pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + pkg-dir@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" @@ -22926,21 +22926,16 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= -progress@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.1.tgz#c9242169342b1c29d275889c95734621b1952e31" - integrity sha512-OE+a6vzqazc+K6LxJrX5UPyKFvGnL5CYmq2jFGNIBWHpc4QyE49/YOumcrpQFJpfejmvRtbJzgO1zPmMCqlbBg== +progress@2.0.3, progress@^2.0.0, progress@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== progress@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" integrity sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74= -progress@^2.0.0, progress@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== - proj4@2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/proj4/-/proj4-2.6.2.tgz#4665d7cbc30fd356375007c2fed53b07dbda1d67" @@ -23190,23 +23185,22 @@ pupa@^2.1.1: dependencies: escape-goat "^2.0.0" -puppeteer@^10.2.0: - version "10.4.0" - resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-10.4.0.tgz#a6465ff97fda0576c4ac29601406f67e6fea3dc7" - integrity sha512-2cP8mBoqnu5gzAVpbZ0fRaobBWZM8GEUF4I1F6WbgHrKV/rz7SX8PG2wMymZgD0wo0UBlg2FBPNxlF/xlqW6+w== +puppeteer@18.1.0: + version "18.1.0" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-18.1.0.tgz#7fa53b29f87dfb3192d415f38a46e35b107ec907" + integrity sha512-2RCVWIF+pZOSfksWlQU0Hh6CeUT5NYt66CDDgRyuReu6EvBAk1y+/Q7DuzYNvGChSecGMb7QPN0hkxAa3guAog== dependencies: - debug "4.3.1" - devtools-protocol "0.0.901419" + cross-fetch "3.1.5" + debug "4.3.4" + devtools-protocol "0.0.1045489" extract-zip "2.0.1" - https-proxy-agent "5.0.0" - node-fetch "2.6.1" - pkg-dir "4.2.0" - progress "2.0.1" + https-proxy-agent "5.0.1" + progress "2.0.3" proxy-from-env "1.1.0" rimraf "3.0.2" - tar-fs "2.0.0" - unbzip2-stream "1.3.3" - ws "7.4.6" + tar-fs "2.1.1" + unbzip2-stream "1.4.3" + ws "8.9.0" q@^1.5.1: version "1.5.1" @@ -26638,17 +26632,7 @@ tape@^5.0.1: string.prototype.trim "^1.2.1" through "^2.3.8" -tar-fs@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.0.0.tgz#677700fc0c8b337a78bee3623fdc235f21d7afad" - integrity sha512-vaY0obB6Om/fso8a8vakQBzwholQ7v5+uy+tF3Ozvxv1KNezmVQAiWtcNmMHFSFPqL3dJA8ha6gdtFbfX9mcxA== - dependencies: - chownr "^1.1.1" - mkdirp "^0.5.1" - pump "^3.0.0" - tar-stream "^2.0.0" - -tar-fs@^2.0.0, tar-fs@^2.1.1: +tar-fs@2.1.1, tar-fs@^2.0.0, tar-fs@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== @@ -26658,7 +26642,7 @@ tar-fs@^2.0.0, tar-fs@^2.1.1: pump "^3.0.0" tar-stream "^2.1.4" -tar-stream@^2.0.0, tar-stream@^2.1.4, tar-stream@^2.2.0: +tar-stream@^2.1.4, tar-stream@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== @@ -27448,10 +27432,10 @@ unbox-primitive@^1.0.1: has-symbols "^1.0.2" which-boxed-primitive "^1.0.2" -unbzip2-stream@1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.3.3.tgz#d156d205e670d8d8c393e1c02ebd506422873f6a" - integrity sha512-fUlAF7U9Ah1Q6EieQ4x4zLNejrRvDWUYmxXUpN3uziFYCHapjWFaCAnreY9bGgxzaMCFAPPpYNng57CypwJVhg== +unbzip2-stream@1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" + integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== dependencies: buffer "^5.2.1" through "^2.3.8" @@ -29112,10 +29096,10 @@ write-file-atomic@^4.0.1: imurmurhash "^0.1.4" signal-exit "^3.0.7" -ws@7.4.6: - version "7.4.6" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" - integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== +ws@8.9.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.9.0.tgz#2a994bb67144be1b53fe2d23c53c028adeb7f45e" + integrity sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg== ws@>=8.7.0, ws@^8.2.3, ws@^8.4.2: version "8.8.0" From 8c65dd16aaa9ea5751b726ca9de9cfe164cefdf1 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Wed, 19 Oct 2022 17:16:43 -0400 Subject: [PATCH 15/43] [Synthetics] project monitor - delete (#143379) * add basic delete route * add additional tests * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * adjust types * adjust types * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * adjust types * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * update delete route to DELETE verb * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * rename helper * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../synthetics/common/constants/rest_api.ts | 2 +- .../synthetics/server/routes/common.ts | 8 +- .../plugins/synthetics/server/routes/index.ts | 2 + .../bulk_cruds/delete_monitor_bulk.ts | 16 +- .../monitor_cruds/delete_monitor_project.ts | 94 ++++ .../project_monitor_formatter.ts | 12 +- .../synthetics_monitor_client.ts | 11 +- .../uptime/rest/delete_monitor_project.ts | 524 ++++++++++++++++++ .../api_integration/apis/uptime/rest/index.ts | 1 + 9 files changed, 653 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts create mode 100644 x-pack/test/api_integration/apis/uptime/rest/delete_monitor_project.ts diff --git a/x-pack/plugins/synthetics/common/constants/rest_api.ts b/x-pack/plugins/synthetics/common/constants/rest_api.ts index 0f43ff654c77..33bd608d8f5e 100644 --- a/x-pack/plugins/synthetics/common/constants/rest_api.ts +++ b/x-pack/plugins/synthetics/common/constants/rest_api.ts @@ -48,6 +48,6 @@ export enum API_URLS { SYNTHETICS_HAS_ZIP_URL_MONITORS = '/internal/uptime/fleet/has_zip_url_monitors', // Project monitor public endpoint - SYNTHETICS_MONITORS_PROJECT_LEGACY = '/api/synthetics/service/project/monitors', SYNTHETICS_MONITORS_PROJECT = '/api/synthetics/project/{projectName}/monitors', + SYNTHETICS_MONITORS_PROJECT_LEGACY = '/api/synthetics/service/project/monitors', } diff --git a/x-pack/plugins/synthetics/server/routes/common.ts b/x-pack/plugins/synthetics/server/routes/common.ts index 84a1a294f27e..3ca580fb2da8 100644 --- a/x-pack/plugins/synthetics/server/routes/common.ts +++ b/x-pack/plugins/synthetics/server/routes/common.ts @@ -51,9 +51,9 @@ export const getMonitors = ( const locationFilter = parseLocationFilter(syntheticsService.locations, locations); const filters = - getFilter('tags', tags) + - getFilter('type', monitorType) + - getFilter('locations.id', locationFilter); + getKqlFilter('tags', tags) + + getKqlFilter('type', monitorType) + + getKqlFilter('locations.id', locationFilter); return savedObjectsClient.find({ type: syntheticsMonitorType, @@ -69,7 +69,7 @@ export const getMonitors = ( }); }; -const getFilter = (field: string, values?: string | string[], operator = 'OR') => { +export const getKqlFilter = (field: string, values?: string | string[], operator = 'OR') => { if (!values) { return ''; } diff --git a/x-pack/plugins/synthetics/server/routes/index.ts b/x-pack/plugins/synthetics/server/routes/index.ts index 8cff5dc88c91..c1c06215c8c1 100644 --- a/x-pack/plugins/synthetics/server/routes/index.ts +++ b/x-pack/plugins/synthetics/server/routes/index.ts @@ -18,6 +18,7 @@ import { getSyntheticsMonitorOverviewRoute, getSyntheticsMonitorRoute, } from './monitor_cruds/get_monitor'; +import { deleteSyntheticsMonitorProjectRoute } from './monitor_cruds/delete_monitor_project'; import { getSyntheticsProjectMonitorsRoute } from './monitor_cruds/get_monitor_project'; import { runOnceSyntheticsMonitorRoute } from './synthetics_service/run_once_monitor'; import { getServiceAllowedRoute } from './synthetics_service/get_service_allowed'; @@ -38,6 +39,7 @@ export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ addSyntheticsMonitorRoute, getSyntheticsEnablementRoute, deleteSyntheticsMonitorRoute, + deleteSyntheticsMonitorProjectRoute, disableSyntheticsRoute, editSyntheticsMonitorRoute, enableSyntheticsRoute, diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/delete_monitor_bulk.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/delete_monitor_bulk.ts index c156c73342cc..862bab6915f3 100644 --- a/x-pack/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/delete_monitor_bulk.ts +++ b/x-pack/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/delete_monitor_bulk.ts @@ -10,7 +10,13 @@ import { formatTelemetryDeleteEvent, sendTelemetryEvents, } from '../../telemetry/monitor_upgrade_sender'; -import { ConfigKey, MonitorFields, SyntheticsMonitor } from '../../../../common/runtime_types'; +import { + ConfigKey, + MonitorFields, + SyntheticsMonitor, + EncryptedSyntheticsMonitor, + EncryptedSyntheticsMonitorWithId, +} from '../../../../common/runtime_types'; import { UptimeServerSetup } from '../../../legacy_uptime/lib/adapters'; import { SyntheticsMonitorClient } from '../../../synthetics_service/synthetics_monitor/synthetics_monitor_client'; import { syntheticsMonitorType } from '../../../../common/types/saved_objects'; @@ -24,7 +30,7 @@ export const deleteMonitorBulk = async ({ }: { savedObjectsClient: SavedObjectsClientContract; server: UptimeServerSetup; - monitors: Array>; + monitors: Array>; syntheticsMonitorClient: SyntheticsMonitorClient; request: KibanaRequest; }) => { @@ -35,10 +41,8 @@ export const deleteMonitorBulk = async ({ const deleteSyncPromise = syntheticsMonitorClient.deleteMonitors( monitors.map((normalizedMonitor) => ({ ...normalizedMonitor.attributes, - id: - (normalizedMonitor.attributes as MonitorFields)[ConfigKey.CUSTOM_HEARTBEAT_ID] || - normalizedMonitor.id, - })), + id: normalizedMonitor.attributes[ConfigKey.CUSTOM_HEARTBEAT_ID] || normalizedMonitor.id, + })) as EncryptedSyntheticsMonitorWithId[], request, savedObjectsClient, spaceId diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts new file mode 100644 index 000000000000..3deb7a1be217 --- /dev/null +++ b/x-pack/plugins/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; +import { ConfigKey } from '../../../common/runtime_types'; +import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes/types'; +import { API_URLS } from '../../../common/constants'; +import { syntheticsMonitorType } from '../../legacy_uptime/lib/saved_objects/synthetics_monitor'; +import { getMonitors, getKqlFilter } from '../common'; +import { INSUFFICIENT_FLEET_PERMISSIONS } from '../../synthetics_service/project_monitor/project_monitor_formatter'; +import { deleteMonitorBulk } from './bulk_cruds/delete_monitor_bulk'; + +export const deleteSyntheticsMonitorProjectRoute: SyntheticsRestApiRouteFactory = () => ({ + method: 'DELETE', + path: API_URLS.SYNTHETICS_MONITORS_PROJECT, + validate: { + body: schema.object({ + monitors: schema.arrayOf(schema.string()), + }), + params: schema.object({ + projectName: schema.string(), + }), + }, + handler: async ({ + request, + response, + savedObjectsClient, + server, + syntheticsMonitorClient, + }): Promise => { + const { projectName } = request.params; + const { monitors: monitorsToDelete } = request.body; + const decodedProjectName = decodeURI(projectName); + if (monitorsToDelete.length > 250) { + return response.badRequest({ + body: { + message: REQUEST_TOO_LARGE, + }, + }); + } + + const { saved_objects: monitors } = await getMonitors( + { + filter: `${syntheticsMonitorType}.attributes.${ + ConfigKey.PROJECT_ID + }: "${decodedProjectName}" AND ${getKqlFilter( + 'journey_id', + monitorsToDelete.map((id: string) => `"${id}"`) + )}`, + fields: [], + perPage: 500, + }, + syntheticsMonitorClient.syntheticsService, + savedObjectsClient + ); + + const { + integrations: { writeIntegrationPolicies }, + } = await server.fleet.authz.fromRequest(request); + + const hasPrivateMonitor = monitors.some((monitor) => + monitor.attributes.locations.some((location) => !location.isServiceManaged) + ); + + if (!writeIntegrationPolicies && hasPrivateMonitor) { + return response.forbidden({ + body: { + message: INSUFFICIENT_FLEET_PERMISSIONS, + }, + }); + } + + await deleteMonitorBulk({ + monitors, + server, + savedObjectsClient, + syntheticsMonitorClient, + request, + }); + + return { + deleted_monitors: monitorsToDelete, + }; + }, +}); + +export const REQUEST_TOO_LARGE = i18n.translate('xpack.synthetics.server.project.delete.toolarge', { + defaultMessage: + 'Delete request payload is too large. Please send a max of 250 monitors to delete per request', +}); diff --git a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/project_monitor_formatter.ts b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/project_monitor_formatter.ts index 16cbf3f33a8a..6139b2fbe795 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/project_monitor_formatter.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/project_monitor_formatter.ts @@ -6,13 +6,14 @@ */ import type { Subject } from 'rxjs'; import { isEqual } from 'lodash'; +import pMap from 'p-map'; import { KibanaRequest } from '@kbn/core/server'; import { SavedObjectsUpdateResponse, SavedObjectsClientContract, SavedObjectsFindResult, } from '@kbn/core/server'; -import pMap from 'p-map'; +import { i18n } from '@kbn/i18n'; import { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import { syncNewMonitorBulk } from '../../routes/monitor_cruds/bulk_cruds/add_monitor_bulk'; import { deleteMonitorBulk } from '../../routes/monitor_cruds/bulk_cruds/delete_monitor_bulk'; @@ -50,8 +51,13 @@ interface StaleMonitor { type StaleMonitorMap = Record; type FailedError = Array<{ id?: string; reason: string; details: string; payload?: object }>; -export const INSUFFICIENT_FLEET_PERMISSIONS = - 'Insufficient permissions. In order to configure private locations, you must have Fleet and Integrations write permissions. To resolve, please generate a new API key with a user who has Fleet and Integrations write permissions.'; +export const INSUFFICIENT_FLEET_PERMISSIONS = i18n.translate( + 'xpack.synthetics.service.projectMonitors.insufficientFleetPermissions', + { + defaultMessage: + 'Insufficient permissions. In order to configure private locations, you must have Fleet and Integrations write permissions. To resolve, please generate a new API key with a user who has Fleet and Integrations write permissions.', + } +); export class ProjectMonitorFormatter { private projectId: string; diff --git a/x-pack/plugins/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.ts b/x-pack/plugins/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.ts index 8af7fca704ab..8218a6f0b3ba 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.ts @@ -13,6 +13,7 @@ import { ConfigKey, MonitorFields, SyntheticsMonitorWithId, + EncryptedSyntheticsMonitorWithId, HeartbeatConfig, PrivateLocation, EncryptedSyntheticsMonitor, @@ -120,19 +121,23 @@ export class SyntheticsMonitorClient { } } async deleteMonitors( - monitors: SyntheticsMonitorWithId[], + monitors: Array, request: KibanaRequest, savedObjectsClient: SavedObjectsClientContract, spaceId: string ) { + /* Type cast encrypted saved objects to decrypted saved objects for delete flow only. + * Deletion does not require all monitor fields */ const privateDeletePromise = this.privateLocationAPI.deleteMonitors( - monitors, + monitors as SyntheticsMonitorWithId[], request, savedObjectsClient, spaceId ); - const publicDeletePromise = this.syntheticsService.deleteConfigs(monitors); + const publicDeletePromise = this.syntheticsService.deleteConfigs( + monitors as SyntheticsMonitorWithId[] + ); const [pubicResponse] = await Promise.all([publicDeletePromise, privateDeletePromise]); return pubicResponse; diff --git a/x-pack/test/api_integration/apis/uptime/rest/delete_monitor_project.ts b/x-pack/test/api_integration/apis/uptime/rest/delete_monitor_project.ts new file mode 100644 index 000000000000..25afc4e66518 --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/delete_monitor_project.ts @@ -0,0 +1,524 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import uuid from 'uuid'; +import expect from '@kbn/expect'; +import { format as formatUrl } from 'url'; +import { ConfigKey, ProjectMonitorsRequest } from '@kbn/synthetics-plugin/common/runtime_types'; +import { INSUFFICIENT_FLEET_PERMISSIONS } from '@kbn/synthetics-plugin/server/synthetics_service/project_monitor/project_monitor_formatter'; +import { REQUEST_TOO_LARGE } from '@kbn/synthetics-plugin/server/routes/monitor_cruds/delete_monitor_project'; +import { API_URLS } from '@kbn/synthetics-plugin/common/constants'; +import { syntheticsMonitorType } from '@kbn/synthetics-plugin/server/legacy_uptime/lib/saved_objects/synthetics_monitor'; +import { PackagePolicy } from '@kbn/fleet-plugin/common'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { getFixtureJson } from './helper/get_fixture_json'; +import { PrivateLocationTestService } from './services/private_location_test_service'; +import { parseStreamApiResponse } from './add_monitor_project'; + +export default function ({ getService }: FtrProviderContext) { + describe('DeleteProjectMonitors', function () { + this.tags('skipCloud'); + + const supertest = getService('supertest'); + const config = getService('config'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const security = getService('security'); + const kibanaServerUrl = formatUrl(config.get('servers.kibana')); + const kibanaServer = getService('kibanaServer'); + const projectMonitorEndpoint = kibanaServerUrl + API_URLS.SYNTHETICS_MONITORS_PROJECT_LEGACY; + + let projectMonitors: ProjectMonitorsRequest; + + let testPolicyId = ''; + const testPrivateLocations = new PrivateLocationTestService(getService); + + const setUniqueIds = (request: ProjectMonitorsRequest) => { + return { + ...request, + monitors: request.monitors.map((monitor) => ({ ...monitor, id: uuid.v4() })), + }; + }; + + before(async () => { + await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); + await supertest + .post('/api/fleet/epm/packages/synthetics/0.10.3') + .set('kbn-xsrf', 'true') + .send({ force: true }) + .expect(200); + + const testPolicyName = 'Fleet test server policy' + Date.now(); + const apiResponse = await testPrivateLocations.addFleetPolicy(testPolicyName); + testPolicyId = apiResponse.body.item.id; + await testPrivateLocations.setTestLocations([testPolicyId]); + }); + + beforeEach(() => { + projectMonitors = setUniqueIds(getFixtureJson('project_browser_monitor')); + }); + + it('only allows 250 requests at a time', async () => { + const project = 'test-brower-suite'; + const monitors = []; + for (let i = 0; i < 251; i++) { + monitors.push({ + ...projectMonitors.monitors[0], + id: `test-id-${i}`, + name: `test-name-${i}`, + }); + } + + try { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + monitors, + }) + ); + + const savedObjectsResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true'); + const { total } = savedObjectsResponse.body; + expect(total).to.eql(251); + const monitorsToDelete = monitors.map((monitor) => monitor.id); + + const response = await supertest + .delete(API_URLS.SYNTHETICS_MONITORS_PROJECT.replace('{projectName}', project)) + .set('kbn-xsrf', 'true') + .send({ monitors: monitorsToDelete }) + .expect(400); + const { message } = response.body; + expect(message).to.eql(REQUEST_TOO_LARGE); + } finally { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + } + }); + + it('project monitors - handles browser monitors', async () => { + const monitorToDelete = 'second-monitor-id'; + const monitors = [ + projectMonitors.monitors[0], + { + ...projectMonitors.monitors[0], + id: monitorToDelete, + }, + ]; + const project = 'test-brower-suite'; + + try { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + monitors, + }) + ); + + const savedObjectsResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total } = savedObjectsResponse.body; + expect(total).to.eql(2); + const monitorsToDelete = [monitorToDelete]; + + const response = await supertest + .delete(API_URLS.SYNTHETICS_MONITORS_PROJECT.replace('{projectName}', project)) + .set('kbn-xsrf', 'true') + .send({ monitors: monitorsToDelete }) + .expect(200); + + expect(response.body.deleted_monitors).to.eql(monitorsToDelete); + + const responseAfterDeletion = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total: totalAfterDeletion } = responseAfterDeletion.body; + expect(totalAfterDeletion).to.eql(1); + } finally { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + } + }); + + it('does not delete monitors from a different project', async () => { + const monitors = [...projectMonitors.monitors]; + const project = 'test-brower-suite'; + const secondProject = 'second-project'; + + try { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + monitors, + }) + ); + + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project: secondProject, + monitors, + }) + ); + + const savedObjectsResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const secondProjectSavedObjectResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${secondProject}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total } = savedObjectsResponse.body; + const { total: secondProjectTotal } = secondProjectSavedObjectResponse.body; + expect(total).to.eql(monitors.length); + expect(secondProjectTotal).to.eql(monitors.length); + const monitorsToDelete = monitors.map((monitor) => monitor.id); + + const response = await supertest + .delete(API_URLS.SYNTHETICS_MONITORS_PROJECT.replace('{projectName}', project)) + .set('kbn-xsrf', 'true') + .send({ monitors: monitorsToDelete }) + .expect(200); + + expect(response.body.deleted_monitors).to.eql(monitorsToDelete); + + const responseAfterDeletion = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const secondResponseAfterDeletion = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${secondProject}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total: totalAfterDeletion } = responseAfterDeletion.body; + const { total: secondProjectTotalAfterDeletion } = secondResponseAfterDeletion.body; + expect(totalAfterDeletion).to.eql(0); + expect(secondProjectTotalAfterDeletion).to.eql(monitors.length); + } finally { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project: secondProject, + keep_stale: false, + monitors: [], + }) + ); + } + }); + + it('does not delete monitors from the same project in a different space project', async () => { + const monitors = [...projectMonitors.monitors]; + const project = 'test-brower-suite'; + const SPACE_ID = `test-space-${uuid.v4()}`; + const SPACE_NAME = `test-space-name ${uuid.v4()}`; + const secondSpaceProjectMonitorApiRoute = `${kibanaServerUrl}/s/${SPACE_ID}${API_URLS.SYNTHETICS_MONITORS_PROJECT_LEGACY}`; + await kibanaServer.spaces.create({ id: SPACE_ID, name: SPACE_NAME }); + + try { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + monitors, + }) + ); + + await parseStreamApiResponse( + secondSpaceProjectMonitorApiRoute, + JSON.stringify({ + ...projectMonitors, + project, + monitors, + }) + ); + + const savedObjectsResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const secondSpaceProjectSavedObjectResponse = await supertest + .get(`/s/${SPACE_ID}${API_URLS.SYNTHETICS_MONITORS}`) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total } = savedObjectsResponse.body; + const { total: secondSpaceTotal } = secondSpaceProjectSavedObjectResponse.body; + + expect(total).to.eql(monitors.length); + expect(secondSpaceTotal).to.eql(monitors.length); + const monitorsToDelete = monitors.map((monitor) => monitor.id); + + const response = await supertest + .delete( + `/s/${SPACE_ID}${API_URLS.SYNTHETICS_MONITORS_PROJECT.replace( + '{projectName}', + project + )}` + ) + .set('kbn-xsrf', 'true') + .send({ monitors: monitorsToDelete }) + .expect(200); + + expect(response.body.deleted_monitors).to.eql(monitorsToDelete); + + const responseAfterDeletion = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const secondSpaceResponseAfterDeletion = await supertest + .get(`/s/${SPACE_ID}${API_URLS.SYNTHETICS_MONITORS}`) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total: totalAfterDeletion } = responseAfterDeletion.body; + const { total: secondProjectTotalAfterDeletion } = secondSpaceResponseAfterDeletion.body; + expect(totalAfterDeletion).to.eql(monitors.length); + expect(secondProjectTotalAfterDeletion).to.eql(0); + } finally { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + await parseStreamApiResponse( + secondSpaceProjectMonitorApiRoute, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + } + }); + + it('deletes integration policies when project monitors are deleted', async () => { + const monitors = [ + { ...projectMonitors.monitors[0], privateLocations: ['Test private location 0'] }, + ]; + const project = 'test-brower-suite'; + + try { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + monitors, + }) + ); + + const savedObjectsResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total } = savedObjectsResponse.body; + expect(total).to.eql(monitors.length); + const apiResponsePolicy = await supertest.get( + '/api/fleet/package_policies?page=1&perPage=2000&kuery=ingest-package-policies.package.name%3A%20synthetics' + ); + + const packagePolicy = apiResponsePolicy.body.items.find( + (pkgPolicy: PackagePolicy) => + pkgPolicy.id === + savedObjectsResponse.body.monitors[0].attributes[ConfigKey.CUSTOM_HEARTBEAT_ID] + + '-' + + testPolicyId + ); + expect(packagePolicy.policy_id).to.be(testPolicyId); + + const monitorsToDelete = monitors.map((monitor) => monitor.id); + + const response = await supertest + .delete(API_URLS.SYNTHETICS_MONITORS_PROJECT.replace('{projectName}', project)) + .set('kbn-xsrf', 'true') + .send({ monitors: monitorsToDelete }) + .expect(200); + + expect(response.body.deleted_monitors).to.eql(monitorsToDelete); + + const responseAfterDeletion = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total: totalAfterDeletion } = responseAfterDeletion.body; + expect(totalAfterDeletion).to.eql(0); + const apiResponsePolicy2 = await supertest.get( + '/api/fleet/package_policies?page=1&perPage=2000&kuery=ingest-package-policies.package.name%3A%20synthetics' + ); + + const packagePolicy2 = apiResponsePolicy2.body.items.find( + (pkgPolicy: PackagePolicy) => + pkgPolicy.id === + savedObjectsResponse.body.monitors[0].attributes[ConfigKey.CUSTOM_HEARTBEAT_ID] + + '-' + + testPolicyId + ); + expect(packagePolicy2).to.be(undefined); + } finally { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + } + }); + + it('returns 403 when a user without fleet permissions attempts to delete a project monitor with a private location', async () => { + const project = 'test-brower-suite'; + const secondMonitor = { + ...projectMonitors.monitors[0], + id: 'test-id-2', + privateLocations: ['Test private location 0'], + }; + const testMonitors = [projectMonitors.monitors[0], secondMonitor]; + const monitorsToDelete = testMonitors.map((monitor) => monitor.id); + const username = 'admin'; + const roleName = 'uptime read only'; + const password = `${username} - password`; + try { + await security.role.create(roleName, { + kibana: [ + { + feature: { + uptime: ['all'], + }, + spaces: ['*'], + }, + ], + }); + await security.user.create(username, { + password, + roles: [roleName], + full_name: 'a kibana user', + }); + + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: testMonitors, + }) + ); + + const savedObjectsResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total } = savedObjectsResponse.body; + expect(total).to.eql(2); + + const { + body: { message }, + } = await supertestWithoutAuth + .delete(API_URLS.SYNTHETICS_MONITORS_PROJECT.replace('{projectName}', project)) + .set('kbn-xsrf', 'true') + .auth(username, password) + .send({ monitors: monitorsToDelete }) + .expect(403); + expect(message).to.eql(INSUFFICIENT_FLEET_PERMISSIONS); + } finally { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + await security.user.delete(username); + await security.role.delete(roleName); + } + }); + }); +} diff --git a/x-pack/test/api_integration/apis/uptime/rest/index.ts b/x-pack/test/api_integration/apis/uptime/rest/index.ts index e1fd22c2baf8..2e3e6f21f34c 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/index.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/index.ts @@ -82,6 +82,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./add_monitor_private_location')); loadTestFile(require.resolve('./edit_monitor')); loadTestFile(require.resolve('./delete_monitor')); + loadTestFile(require.resolve('./delete_monitor_project')); loadTestFile(require.resolve('./synthetics_enablement')); }); }); From 1994a162de80e977aa12f4c7af5ecae6947f8042 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 19 Oct 2022 14:26:25 -0700 Subject: [PATCH 16/43] [lens] move `open_in_lens` out of ftr group3 (#143666) --- .buildkite/ftr_configs.yml | 1 + .../test/functional/apps/lens/group3/index.ts | 1 - .../apps/lens/group3/open_in_lens/index.ts | 15 ---- .../open_in_lens/agg_based/gauge.ts | 2 +- .../open_in_lens/agg_based/goal.ts | 2 +- .../open_in_lens/agg_based/index.ts | 2 +- .../open_in_lens/agg_based/metric.ts | 2 +- .../open_in_lens/agg_based/pie.ts | 2 +- .../open_in_lens/agg_based/table.ts | 2 +- .../{group3 => }/open_in_lens/agg_based/xy.ts | 2 +- .../apps/lens/open_in_lens/config.ts | 17 +++++ .../apps/lens/open_in_lens/index.ts | 75 +++++++++++++++++++ .../open_in_lens/tsvb/dashboard.ts | 2 +- .../{group3 => }/open_in_lens/tsvb/gauge.ts | 2 +- .../{group3 => }/open_in_lens/tsvb/index.ts | 2 +- .../{group3 => }/open_in_lens/tsvb/metric.ts | 2 +- .../open_in_lens/tsvb/timeseries.ts | 2 +- .../{group3 => }/open_in_lens/tsvb/top_n.ts | 2 +- 18 files changed, 106 insertions(+), 29 deletions(-) delete mode 100644 x-pack/test/functional/apps/lens/group3/open_in_lens/index.ts rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/agg_based/gauge.ts (98%) rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/agg_based/goal.ts (98%) rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/agg_based/index.ts (89%) rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/agg_based/metric.ts (99%) rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/agg_based/pie.ts (98%) rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/agg_based/table.ts (99%) rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/agg_based/xy.ts (99%) create mode 100644 x-pack/test/functional/apps/lens/open_in_lens/config.ts create mode 100644 x-pack/test/functional/apps/lens/open_in_lens/index.ts rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/tsvb/dashboard.ts (98%) rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/tsvb/gauge.ts (98%) rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/tsvb/index.ts (89%) rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/tsvb/metric.ts (98%) rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/tsvb/timeseries.ts (99%) rename x-pack/test/functional/apps/lens/{group3 => }/open_in_lens/tsvb/top_n.ts (99%) diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index f16b0e46ada9..aa35797d1f98 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -176,6 +176,7 @@ enabled: - x-pack/test/functional/apps/lens/group1/config.ts - x-pack/test/functional/apps/lens/group2/config.ts - x-pack/test/functional/apps/lens/group3/config.ts + - x-pack/test/functional/apps/lens/open_in_lens/config.ts - x-pack/test/functional/apps/license_management/config.ts - x-pack/test/functional/apps/logstash/config.ts - x-pack/test/functional/apps/management/config.ts diff --git a/x-pack/test/functional/apps/lens/group3/index.ts b/x-pack/test/functional/apps/lens/group3/index.ts index 627e9d560ca2..30c8624d876b 100644 --- a/x-pack/test/functional/apps/lens/group3/index.ts +++ b/x-pack/test/functional/apps/lens/group3/index.ts @@ -86,7 +86,6 @@ export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext loadTestFile(require.resolve('./error_handling')); loadTestFile(require.resolve('./lens_tagging')); loadTestFile(require.resolve('./lens_reporting')); - loadTestFile(require.resolve('./open_in_lens')); // keep these two last in the group in this order because they are messing with the default saved objects loadTestFile(require.resolve('./rollup')); loadTestFile(require.resolve('./no_data')); diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/index.ts b/x-pack/test/functional/apps/lens/group3/open_in_lens/index.ts deleted file mode 100644 index b1d5a1cbb3c5..000000000000 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FtrProviderContext } from '../../../../ftr_provider_context'; - -export default function ({ loadTestFile }: FtrProviderContext) { - describe('Open in Lens', function () { - loadTestFile(require.resolve('./tsvb')); - loadTestFile(require.resolve('./agg_based')); - }); -} diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/gauge.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/gauge.ts similarity index 98% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/gauge.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/gauge.ts index 0d85d363d8b8..35838915ede3 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/gauge.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/gauge.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, lens, timePicker, visEditor, visChart } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/goal.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/goal.ts similarity index 98% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/goal.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/goal.ts index 547d15856b7f..d5b793b26713 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/goal.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/goal.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, lens, visChart, timePicker, visEditor } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/index.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/index.ts similarity index 89% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/index.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/index.ts index 0737c7ffeeb5..87c9d025893a 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/index.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Agg based Vis to Lens', function () { diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/metric.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/metric.ts similarity index 99% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/metric.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/metric.ts index eef46d2c0cdb..4958704801c8 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/metric.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/metric.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visEditor, visualize, lens, timePicker, visChart } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/pie.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/pie.ts similarity index 98% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/pie.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/pie.ts index ed08f1ea5ae0..346aada45cea 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/pie.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/pie.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visEditor, lens, timePicker, header } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/table.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/table.ts similarity index 99% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/table.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/table.ts index c03773e3276b..1497eea84c85 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/table.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/table.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visEditor, lens, timePicker, header } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/xy.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/xy.ts similarity index 99% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/xy.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/xy.ts index 9fd425984e3c..4c966536001a 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/xy.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/xy.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visEditor, lens, timePicker, header, visChart } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/open_in_lens/config.ts b/x-pack/test/functional/apps/lens/open_in_lens/config.ts new file mode 100644 index 000000000000..d927f93adeff --- /dev/null +++ b/x-pack/test/functional/apps/lens/open_in_lens/config.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js')); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('.')], + }; +} diff --git a/x-pack/test/functional/apps/lens/open_in_lens/index.ts b/x-pack/test/functional/apps/lens/open_in_lens/index.ts new file mode 100644 index 000000000000..5d81bfcb9a92 --- /dev/null +++ b/x-pack/test/functional/apps/lens/open_in_lens/index.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EsArchiver } from '@kbn/es-archiver'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext) => { + const browser = getService('browser'); + const log = getService('log'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects(['timePicker']); + const config = getService('config'); + let remoteEsArchiver; + + describe('lens app - Open in Lens', () => { + const esArchive = 'x-pack/test/functional/es_archives/logstash_functional'; + const localIndexPatternString = 'logstash-*'; + const remoteIndexPatternString = 'ftr-remote:logstash-*'; + const localFixtures = { + lensBasic: 'x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json', + lensDefault: 'x-pack/test/functional/fixtures/kbn_archiver/lens/default', + }; + + const remoteFixtures = { + lensBasic: 'x-pack/test/functional/fixtures/kbn_archiver/lens/ccs/lens_basic.json', + lensDefault: 'x-pack/test/functional/fixtures/kbn_archiver/lens/ccs/default', + }; + let esNode: EsArchiver; + let fixtureDirs: { + lensBasic: string; + lensDefault: string; + }; + let indexPatternString: string; + before(async () => { + log.debug('Starting lens before method'); + await browser.setWindowSize(1280, 1200); + try { + config.get('esTestCluster.ccs'); + remoteEsArchiver = getService('remoteEsArchiver' as 'esArchiver'); + esNode = remoteEsArchiver; + fixtureDirs = remoteFixtures; + indexPatternString = remoteIndexPatternString; + } catch (error) { + esNode = esArchiver; + fixtureDirs = localFixtures; + indexPatternString = localIndexPatternString; + } + + await esNode.load(esArchive); + // changing the timepicker default here saves us from having to set it in Discover (~8s) + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update({ + defaultIndex: indexPatternString, + 'dateFormat:tz': 'UTC', + }); + await kibanaServer.importExport.load(fixtureDirs.lensBasic); + await kibanaServer.importExport.load(fixtureDirs.lensDefault); + }); + + after(async () => { + await esArchiver.unload(esArchive); + await PageObjects.timePicker.resetDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.importExport.unload(fixtureDirs.lensBasic); + await kibanaServer.importExport.unload(fixtureDirs.lensDefault); + }); + + loadTestFile(require.resolve('./tsvb')); + loadTestFile(require.resolve('./agg_based')); + }); +}; diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/dashboard.ts b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/dashboard.ts similarity index 98% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/dashboard.ts rename to x-pack/test/functional/apps/lens/open_in_lens/tsvb/dashboard.ts index 292aaa3a36f0..72daa5ff5486 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/dashboard.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/dashboard.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visualBuilder, lens, timeToVisualize, dashboard, canvas } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/gauge.ts b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/gauge.ts similarity index 98% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/gauge.ts rename to x-pack/test/functional/apps/lens/open_in_lens/tsvb/gauge.ts index 872ce7a58a22..4655fd34accf 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/gauge.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/gauge.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visualBuilder, lens, header } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/index.ts b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/index.ts similarity index 89% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/index.ts rename to x-pack/test/functional/apps/lens/open_in_lens/tsvb/index.ts index ea859195e634..c0b5197983aa 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/index.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('TSVB to Lens', function () { diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/metric.ts b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/metric.ts similarity index 98% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/metric.ts rename to x-pack/test/functional/apps/lens/open_in_lens/tsvb/metric.ts index 794a2be110a3..081b3787e39a 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/metric.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/metric.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visualBuilder, lens, header } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/timeseries.ts b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/timeseries.ts similarity index 99% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/timeseries.ts rename to x-pack/test/functional/apps/lens/open_in_lens/tsvb/timeseries.ts index 4c0c7e66b1ba..dc77e9fcedb9 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/timeseries.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/timeseries.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visualBuilder, lens, header } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/top_n.ts b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/top_n.ts similarity index 99% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/top_n.ts rename to x-pack/test/functional/apps/lens/open_in_lens/tsvb/top_n.ts index 0631872fc9bd..1192b38b03c6 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/top_n.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/top_n.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visualBuilder, lens, header } = getPageObjects([ From c25eedc40208d9b305b26ad3f716c3db8e32ea9b Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Wed, 19 Oct 2022 17:36:13 -0400 Subject: [PATCH 17/43] [Synthetics UI] Prefer `custom_heartbeat_id` to `monitor.id` for Uptime detail link when present (#143128) * Prefer `custom_heartbeat_id` to `monitor.id` when present. * Prefer `ConfigKey`. --- .../monitor_details/monitor_summary/last_ten_test_runs.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx index a424a831c97f..8d48a45a391c 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx @@ -118,6 +118,7 @@ export const LastTenTestRuns = () => { }, ]; + const historyIdParam = monitor?.[ConfigKey.CUSTOM_HEARTBEAT_ID] ?? monitor?.[ConfigKey.ID]; return ( @@ -133,7 +134,8 @@ export const LastTenTestRuns = () => { iconType="list" iconSide="left" data-test-subj="monitorSummaryViewLastTestRun" - href={`${basePath}/app/uptime/monitor/${btoa(monitor?.id ?? '')}`} + disabled={!historyIdParam} + href={`${basePath}/app/uptime/monitor/${btoa(historyIdParam ?? '')}`} > {i18n.translate('xpack.synthetics.monitorDetails.summary.viewHistory', { defaultMessage: 'View History', From 64b5efebdd23cd67f3919dd31b1254fe9e294f90 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Wed, 19 Oct 2022 15:52:39 -0700 Subject: [PATCH 18/43] Cleaned up Observability plugin from timelines unused dependencies on TGrid usage (#143607) * Clean Observability plugin from timelines unused deps on TGrid usage * - * End of standalone version of TGrid * fixed unused deps * - * Clean up variables * Fixed tests * FIxed tests * Removed unused tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- tsconfig.base.json | 2 - x-pack/plugins/observability/kibana.json | 1 - .../observability/public/application/types.ts | 3 - .../register_alerts_table_configuration.tsx | 6 +- .../hooks/use_alert_bulk_case_actions.ts | 2 +- .../components/default_cell_actions.tsx | 42 -- .../public/pages/alerts/components/index.ts | 1 - .../components/observability_actions.tsx | 5 +- .../components/render_cell_value/index.ts | 2 +- .../add_display_names.ts | 4 +- .../alerts_table/default_columns.tsx | 52 +++ .../get_row_actions.tsx | 0 .../translations.ts | 0 .../alerts_table_t_grid.tsx | 331 --------------- .../containers/alerts_table_t_grid/index.ts | 8 - .../public/pages/alerts/containers/index.ts | 1 - .../timeline_actions/use_alerts_actions.tsx | 1 - .../common/types/timeline/actions/index.ts | 1 - .../timelines/public/components/index.tsx | 11 +- .../public/components/t_grid/index.tsx | 5 +- .../components/t_grid/standalone/index.tsx | 394 ------------------ .../bulk_actions/alert_bulk_actions.tsx | 1 - .../public/hooks/use_bulk_action_items.tsx | 4 +- .../timelines/public/methods/index.tsx | 2 +- x-pack/plugins/timelines/public/plugin.ts | 16 - x-pack/plugins/timelines/public/types.ts | 12 +- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../apps/observability/pages/alerts/index.ts | 9 - x-pack/test/plugin_functional/config.ts | 4 - .../plugins/timelines_test/kibana.json | 11 - .../applications/timelines_test/index.tsx | 100 ----- .../plugins/timelines_test/public/index.ts | 20 - .../plugins/timelines_test/public/plugin.ts | 54 --- .../test_suites/timelines/index.ts | 24 -- 36 files changed, 67 insertions(+), 1065 deletions(-) delete mode 100644 x-pack/plugins/observability/public/pages/alerts/components/default_cell_actions.tsx rename x-pack/plugins/observability/public/pages/alerts/containers/{alerts_table_t_grid => alerts_table}/add_display_names.ts (90%) create mode 100644 x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/default_columns.tsx rename x-pack/plugins/observability/public/pages/alerts/containers/{alerts_table_t_grid => alerts_table}/get_row_actions.tsx (100%) rename x-pack/plugins/observability/public/pages/alerts/containers/{alerts_table_t_grid => alerts_table}/translations.ts (100%) delete mode 100644 x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx delete mode 100644 x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/index.ts delete mode 100644 x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx delete mode 100644 x-pack/test/plugin_functional/plugins/timelines_test/kibana.json delete mode 100644 x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx delete mode 100644 x-pack/test/plugin_functional/plugins/timelines_test/public/index.ts delete mode 100644 x-pack/test/plugin_functional/plugins/timelines_test/public/plugin.ts delete mode 100644 x-pack/test/plugin_functional/test_suites/timelines/index.ts diff --git a/tsconfig.base.json b/tsconfig.base.json index 72b98c7af3c8..6d42c567d342 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -475,8 +475,6 @@ "@kbn/global-search-test-plugin/*": ["x-pack/test/plugin_functional/plugins/global_search_test/*"], "@kbn/resolver-test-plugin": ["x-pack/test/plugin_functional/plugins/resolver_test"], "@kbn/resolver-test-plugin/*": ["x-pack/test/plugin_functional/plugins/resolver_test/*"], - "@kbn/timelines-test-plugin": ["x-pack/test/plugin_functional/plugins/timelines_test"], - "@kbn/timelines-test-plugin/*": ["x-pack/test/plugin_functional/plugins/timelines_test/*"], "@kbn/security-test-endpoints-plugin": ["x-pack/test/security_functional/fixtures/common/test_endpoints"], "@kbn/security-test-endpoints-plugin/*": ["x-pack/test/security_functional/fixtures/common/test_endpoints/*"], "@kbn/application-usage-test-plugin": ["x-pack/test/usage_collection/plugins/application_usage_test"], diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index 365bc50e8abf..deb8859002bc 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -27,7 +27,6 @@ "features", "inspector", "ruleRegistry", - "timelines", "triggersActionsUi", "inspector", "unifiedSearch", diff --git a/x-pack/plugins/observability/public/application/types.ts b/x-pack/plugins/observability/public/application/types.ts index 7e76a46c03c0..4707760c63e3 100644 --- a/x-pack/plugins/observability/public/application/types.ts +++ b/x-pack/plugins/observability/public/application/types.ts @@ -24,8 +24,6 @@ import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { LensPublicStart } from '@kbn/lens-plugin/public'; import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import { CasesUiStart } from '@kbn/cases-plugin/public'; -import { TimelinesUIStart } from '@kbn/timelines-plugin/public'; - export interface ObservabilityAppServices { application: ApplicationStart; cases: CasesUiStart; @@ -42,7 +40,6 @@ export interface ObservabilityAppServices { stateTransfer: EmbeddableStateTransfer; storage: IStorageWrapper; theme: ThemeServiceStart; - timelines: TimelinesUIStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; uiSettings: IUiSettingsClient; isDev?: boolean; diff --git a/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx b/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx index bc41b8d80328..d6ac49b736a5 100644 --- a/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx +++ b/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx @@ -12,9 +12,9 @@ import { casesFeatureId, observabilityFeatureId } from '../../common'; import { useBulkAddToCaseActions } from '../hooks/use_alert_bulk_case_actions'; import { TopAlert, useToGetInternalFlyout } from '../pages/alerts'; import { getRenderCellValue } from '../pages/alerts/components/render_cell_value'; -import { addDisplayNames } from '../pages/alerts/containers/alerts_table_t_grid/add_display_names'; -import { columns as alertO11yColumns } from '../pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid'; -import { getRowActions } from '../pages/alerts/containers/alerts_table_t_grid/get_row_actions'; +import { addDisplayNames } from '../pages/alerts/containers/alerts_table/add_display_names'; +import { columns as alertO11yColumns } from '../pages/alerts/containers/alerts_table/default_columns'; +import { getRowActions } from '../pages/alerts/containers/alerts_table/get_row_actions'; import type { ObservabilityRuleTypeRegistry } from '../rules/create_observability_rule_type_registry'; import type { ConfigSchema } from '../plugin'; diff --git a/x-pack/plugins/observability/public/hooks/use_alert_bulk_case_actions.ts b/x-pack/plugins/observability/public/hooks/use_alert_bulk_case_actions.ts index 40219feb09c2..6e4b915eccfe 100644 --- a/x-pack/plugins/observability/public/hooks/use_alert_bulk_case_actions.ts +++ b/x-pack/plugins/observability/public/hooks/use_alert_bulk_case_actions.ts @@ -12,7 +12,7 @@ import { ADD_TO_CASE_DISABLED, ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE, -} from '../pages/alerts/containers/alerts_table_t_grid/translations'; +} from '../pages/alerts/containers/alerts_table/translations'; import { useGetUserCasesPermissions } from './use_get_user_cases_permissions'; import { ObservabilityAppServices } from '../application/types'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/default_cell_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/components/default_cell_actions.tsx deleted file mode 100644 index 115fa703459b..000000000000 --- a/x-pack/plugins/observability/public/pages/alerts/components/default_cell_actions.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { TimelineNonEcsData } from '@kbn/timelines-plugin/common/search_strategy'; -import { TGridCellAction } from '@kbn/timelines-plugin/common/types/timeline'; -import { getPageRowIndex } from '@kbn/timelines-plugin/public'; -import FilterForValueButton from './filter_for_value'; -import { getMappedNonEcsValue } from './render_cell_value'; - -export const FILTER_FOR_VALUE = i18n.translate('xpack.observability.hoverActions.filterForValue', { - defaultMessage: 'Filter for value', -}); - -/** actions for adding filters to the search bar */ -const buildFilterCellActions = (addToQuery: (value: string) => void): TGridCellAction[] => [ - ({ data, pageSize }: { data: TimelineNonEcsData[][]; pageSize: number }) => - ({ rowIndex, columnId, Component }) => { - const value = getMappedNonEcsValue({ - data: data[getPageRowIndex(rowIndex, pageSize)], - fieldName: columnId, - }); - - return ( - - ); - }, -]; - -/** returns the default actions shown in `EuiDataGrid` cells */ -export const getDefaultCellActions = ({ addToQuery }: { addToQuery: (value: string) => void }) => - buildFilterCellActions(addToQuery); diff --git a/x-pack/plugins/observability/public/pages/alerts/components/index.ts b/x-pack/plugins/observability/public/pages/alerts/components/index.ts index 113e4b86c0e7..592ab16ddcad 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/index.ts +++ b/x-pack/plugins/observability/public/pages/alerts/components/index.ts @@ -10,7 +10,6 @@ export * from './render_cell_value'; export * from './severity_badge'; export * from './workflow_status_filter'; export * from './alerts_search_bar'; -export * from './default_cell_actions'; export * from './filter_for_value'; export * from './parse_alert'; export * from './alerts_status_filter'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx index 8bed941ce174..0583b9a35eb6 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx @@ -24,10 +24,7 @@ import { useKibana } from '../../../utils/kibana_react'; import { useGetUserCasesPermissions } from '../../../hooks/use_get_user_cases_permissions'; import { parseAlert } from './parse_alert'; import { translations, paths } from '../../../config'; -import { - ADD_TO_EXISTING_CASE, - ADD_TO_NEW_CASE, -} from '../containers/alerts_table_t_grid/translations'; +import { ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE } from '../containers/alerts_table/translations'; import { ObservabilityAppServices } from '../../../application/types'; import { RULE_DETAILS_PAGE_ID } from '../../rule_details/types'; import type { TopAlert } from '../containers/alerts_page/types'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/render_cell_value/index.ts b/x-pack/plugins/observability/public/pages/alerts/components/render_cell_value/index.ts index b6df77f07588..009feb015eef 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/render_cell_value/index.ts +++ b/x-pack/plugins/observability/public/pages/alerts/components/render_cell_value/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { getRenderCellValue, getMappedNonEcsValue } from './render_cell_value'; +export { getRenderCellValue } from './render_cell_value'; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/add_display_names.ts b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/add_display_names.ts similarity index 90% rename from x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/add_display_names.ts rename to x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/add_display_names.ts index ef36911a9353..1f2efcbd6d7e 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/add_display_names.ts +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/add_display_names.ts @@ -6,12 +6,10 @@ */ import { ALERT_DURATION, ALERT_REASON, ALERT_STATUS, TIMESTAMP } from '@kbn/rule-data-utils'; import { EuiDataGridColumn } from '@elastic/eui'; -import type { ColumnHeaderOptions } from '@kbn/timelines-plugin/common'; import { translations } from '../../../../config'; export const addDisplayNames = ( - column: Pick & - ColumnHeaderOptions + column: Pick ) => { if (column.id === ALERT_REASON) { return { ...column, displayAsText: translations.alertsTable.reasonColumnDescription }; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/default_columns.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/default_columns.tsx new file mode 100644 index 000000000000..4c187514d41d --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/default_columns.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * We need to produce types and code transpilation at different folders during the build of the package. + * We have types and code at different imports because we don't want to import the whole package in the resulting webpack bundle for the plugin. + * This way plugins can do targeted imports to reduce the final code bundle + */ +import { ALERT_DURATION, ALERT_REASON, ALERT_STATUS, TIMESTAMP } from '@kbn/rule-data-utils'; + +import { EuiDataGridColumn } from '@elastic/eui'; + +import type { ColumnHeaderOptions } from '@kbn/timelines-plugin/common'; + +import { translations } from '../../../../config'; + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick & ColumnHeaderOptions +> = [ + { + columnHeaderType: 'not-filtered', + displayAsText: translations.alertsTable.statusColumnDescription, + id: ALERT_STATUS, + initialWidth: 110, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: translations.alertsTable.lastUpdatedColumnDescription, + id: TIMESTAMP, + initialWidth: 230, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: translations.alertsTable.durationColumnDescription, + id: ALERT_DURATION, + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: translations.alertsTable.reasonColumnDescription, + id: ALERT_REASON, + linkField: '*', + }, +]; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/get_row_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/get_row_actions.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/get_row_actions.tsx rename to x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/get_row_actions.tsx diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/translations.ts b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/translations.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/translations.ts rename to x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/translations.ts diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx deleted file mode 100644 index bcb2495d88b8..000000000000 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx +++ /dev/null @@ -1,331 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/** - * We need to produce types and code transpilation at different folders during the build of the package. - * We have types and code at different imports because we don't want to import the whole package in the resulting webpack bundle for the plugin. - * This way plugins can do targeted imports to reduce the final code bundle - */ -import { - ALERT_DURATION, - ALERT_EVALUATION_THRESHOLD, - ALERT_EVALUATION_VALUE, - ALERT_REASON, - ALERT_RULE_CATEGORY, - ALERT_RULE_NAME, - ALERT_STATUS, - ALERT_UUID, - TIMESTAMP, - ALERT_START, -} from '@kbn/rule-data-utils'; - -import { EuiDataGridColumn, EuiFlexGroup } from '@elastic/eui'; - -import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; - -import styled from 'styled-components'; -import React, { Suspense, useMemo, useState, useCallback, useEffect } from 'react'; - -import { pick } from 'lodash'; -import type { - TGridType, - TGridState, - TGridModel, - SortDirection, -} from '@kbn/timelines-plugin/public'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import type { - ActionProps, - ColumnHeaderOptions, - ControlColumnProps, - RowRenderer, -} from '@kbn/timelines-plugin/common'; -import { getAlertsPermissions } from '../../../../hooks/use_alert_permission'; - -import type { TopAlert } from '../alerts_page/types'; - -import { getRenderCellValue } from '../../components/render_cell_value'; -import { observabilityAppId, observabilityFeatureId } from '../../../../../common'; -import { useGetUserCasesPermissions } from '../../../../hooks/use_get_user_cases_permissions'; -import { usePluginContext } from '../../../../hooks/use_plugin_context'; -import { LazyAlertsFlyout } from '../../../..'; -import { translations } from '../../../../config'; -import { addDisplayNames } from './add_display_names'; -import { ObservabilityAppServices } from '../../../../application/types'; -import { useBulkAddToCaseActions } from '../../../../hooks/use_alert_bulk_case_actions'; -import { - ObservabilityActions, - ObservabilityActionsProps, -} from '../../components/observability_actions'; - -interface AlertsTableTGridProps { - indexNames: string[]; - rangeFrom: string; - rangeTo: string; - kuery?: string; - stateStorageKey: string; - storage: IStorageWrapper; - setRefetch: (ref: () => void) => void; - itemsPerPage?: number; -} - -const EventsThContent = styled.div.attrs(({ className = '' }) => ({ - className: `siemEventsTable__thContent ${className}`, -}))<{ textAlign?: string; width?: number }>` - font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; - font-weight: ${({ theme }) => theme.eui.euiFontWeightBold}; - line-height: ${({ theme }) => theme.eui.euiLineHeight}; - min-width: 0; - padding: ${({ theme }) => theme.eui.euiSizeXS}; - text-align: ${({ textAlign }) => textAlign}; - width: ${({ width }) => - width != null - ? `${width}px` - : '100%'}; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ - - > button.euiButtonIcon, - > .euiToolTipAnchor > button.euiButtonIcon { - margin-left: ${({ theme }) => `-${theme.eui.euiSizeXS}`}; - } -`; -/** - * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, - * plus additional TGrid column properties - */ -export const columns: Array< - Pick & ColumnHeaderOptions -> = [ - { - columnHeaderType: 'not-filtered', - displayAsText: translations.alertsTable.statusColumnDescription, - id: ALERT_STATUS, - initialWidth: 110, - }, - { - columnHeaderType: 'not-filtered', - displayAsText: translations.alertsTable.lastUpdatedColumnDescription, - id: TIMESTAMP, - initialWidth: 230, - }, - { - columnHeaderType: 'not-filtered', - displayAsText: translations.alertsTable.durationColumnDescription, - id: ALERT_DURATION, - initialWidth: 116, - }, - { - columnHeaderType: 'not-filtered', - displayAsText: translations.alertsTable.reasonColumnDescription, - id: ALERT_REASON, - linkField: '*', - }, -]; - -const NO_ROW_RENDER: RowRenderer[] = []; - -const trailingControlColumns: never[] = []; - -const FIELDS_WITHOUT_CELL_ACTIONS = [ - '@timestamp', - 'signal.rule.risk_score', - 'signal.reason', - 'kibana.alert.duration.us', - 'kibana.alert.reason', -]; - -export function AlertsTableTGrid(props: AlertsTableTGridProps) { - const { - indexNames, - rangeFrom, - rangeTo, - kuery, - setRefetch, - stateStorageKey, - storage, - itemsPerPage, - } = props; - - const { - timelines, - application: { capabilities }, - } = useKibana().services; - const { observabilityRuleTypeRegistry, config } = usePluginContext(); - - const [flyoutAlert, setFlyoutAlert] = useState(undefined); - const [tGridState, setTGridState] = useState | null>( - storage.get(stateStorageKey) - ); - - const userCasesPermissions = useGetUserCasesPermissions(); - - const hasAlertsCrudPermissions = useCallback( - ({ ruleConsumer, ruleProducer }: { ruleConsumer: string; ruleProducer?: string }) => { - if (ruleConsumer === 'alerts' && ruleProducer) { - return getAlertsPermissions(capabilities, ruleProducer).crud; - } - return getAlertsPermissions(capabilities, ruleConsumer).crud; - }, - [capabilities] - ); - - const [deletedEventIds, setDeletedEventIds] = useState([]); - - useEffect(() => { - if (tGridState) { - const newState = { - ...tGridState, - columns: tGridState.columns?.map((c) => - pick(c, ['columnHeaderType', 'displayAsText', 'id', 'initialWidth', 'linkField']) - ), - }; - if (newState !== storage.get(stateStorageKey)) { - storage.set(stateStorageKey, newState); - } - } - }, [tGridState, stateStorageKey, storage]); - - const setEventsDeleted = useCallback((action) => { - if (action.isDeleted) { - setDeletedEventIds((ids) => [...ids, ...action.eventIds]); - } - }, []); - - const leadingControlColumns: ControlColumnProps[] = useMemo(() => { - return [ - { - id: 'expand', - width: 120, - headerCellRender: () => { - return {translations.alertsTable.actionsTextLabel}; - }, - rowCellRender: (actionProps: ActionProps) => { - return ( - - - - ); - }, - }, - ]; - }, [setEventsDeleted, observabilityRuleTypeRegistry, config]); - - const onStateChange = useCallback( - (state: TGridState) => { - const pickedState = pick(state.tableById['standalone-t-grid'], [ - 'columns', - 'sort', - 'selectedEventIds', - ]); - if (JSON.stringify(pickedState) !== JSON.stringify(tGridState)) { - setTGridState(pickedState); - } - }, - [tGridState] - ); - - const addToCaseBulkActions = useBulkAddToCaseActions(); - const bulkActions = useMemo( - () => ({ - alertStatusActions: false, - customBulkActions: addToCaseBulkActions, - }), - [addToCaseBulkActions] - ); - const tGridProps = useMemo(() => { - const type: TGridType = 'standalone'; - const sortDirection: SortDirection = 'desc'; - return { - appId: observabilityAppId, - casesOwner: observabilityFeatureId, - casePermissions: userCasesPermissions, - type, - columns: (tGridState?.columns ?? columns).map(addDisplayNames), - deletedEventIds, - disabledCellActions: FIELDS_WITHOUT_CELL_ACTIONS, - end: rangeTo, - filters: [], - hasAlertsCrudPermissions, - indexNames, - itemsPerPage, - itemsPerPageOptions: [10, 25, 50], - loadingText: translations.alertsTable.loadingTextLabel, - onStateChange, - query: { - query: kuery ?? '', - language: 'kuery', - }, - renderCellValue: getRenderCellValue({ setFlyoutAlert, observabilityRuleTypeRegistry }), - rowRenderers: NO_ROW_RENDER, - // TODO: implement Kibana data view runtime fields in observability - runtimeMappings: {}, - start: rangeFrom, - setRefetch, - bulkActions, - sort: tGridState?.sort ?? [ - { - columnId: '@timestamp', - columnType: 'date', - sortDirection, - }, - ], - queryFields: [ - ALERT_DURATION, - ALERT_EVALUATION_THRESHOLD, - ALERT_EVALUATION_VALUE, - ALERT_REASON, - ALERT_RULE_CATEGORY, - ALERT_RULE_NAME, - ALERT_STATUS, - ALERT_UUID, - ALERT_START, - TIMESTAMP, - ], - leadingControlColumns, - trailingControlColumns, - unit: (totalAlerts: number) => translations.alertsTable.showingAlertsTitle(totalAlerts), - }; - }, [ - userCasesPermissions, - tGridState?.columns, - tGridState?.sort, - deletedEventIds, - rangeTo, - hasAlertsCrudPermissions, - indexNames, - itemsPerPage, - observabilityRuleTypeRegistry, - onStateChange, - kuery, - rangeFrom, - setRefetch, - bulkActions, - leadingControlColumns, - ]); - - const handleFlyoutClose = () => setFlyoutAlert(undefined); - - return ( - <> - {flyoutAlert && ( - - - - )} - {timelines.getTGrid<'standalone'>(tGridProps)} - - ); -} diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/index.ts b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/index.ts deleted file mode 100644 index 7bbcc43230a4..000000000000 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { AlertsTableTGrid } from './alerts_table_t_grid'; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/index.ts b/x-pack/plugins/observability/public/pages/alerts/containers/index.ts index 074f48f42664..23b65105b788 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/index.ts +++ b/x-pack/plugins/observability/public/pages/alerts/containers/index.ts @@ -6,5 +6,4 @@ */ export * from './alerts_page'; -export * from './alerts_table_t_grid'; export * from './state_container'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx index 32f2d9d88994..c0c3b35a72ac 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx @@ -67,7 +67,6 @@ export const useAlertsActions = ({ setEventsDeleted, onUpdateSuccess: onStatusUpdate, onUpdateFailure: onStatusUpdate, - scopeId, }); return { diff --git a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts index 4cac68268abc..cde9b04d0e70 100644 --- a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts @@ -92,7 +92,6 @@ export interface BulkActionsProps { onUpdateSuccess?: OnUpdateAlertStatusSuccess; onUpdateFailure?: OnUpdateAlertStatusError; customBulkActions?: CustomBulkActionProp[]; - scopeId?: string; } export interface HeaderActionProps { diff --git a/x-pack/plugins/timelines/public/components/index.tsx b/x-pack/plugins/timelines/public/components/index.tsx index 296e7841aa37..cc8446eea141 100644 --- a/x-pack/plugins/timelines/public/components/index.tsx +++ b/x-pack/plugins/timelines/public/components/index.tsx @@ -6,18 +6,16 @@ */ import React from 'react'; -import { Provider } from 'react-redux'; import { I18nProvider } from '@kbn/i18n-react'; import type { Store } from 'redux'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { createStore } from '../store/t_grid'; +import { Provider } from 'react-redux'; import { TGrid as TGridComponent } from './t_grid'; import type { TGridProps } from '../types'; import { DragDropContextWrapper } from './drag_and_drop'; -import { initialTGridState } from '../store/t_grid/reducer'; import type { TGridIntegratedProps } from './t_grid/integrated'; const EMPTY_BROWSER_FIELDS = {}; @@ -31,18 +29,13 @@ type TGridComponent = TGridProps & { export const TGrid = (props: TGridComponent) => { const { store, storage, setStore, ...tGridProps } = props; - let tGridStore = store; - if (!tGridStore && props.type === 'standalone') { - tGridStore = createStore(initialTGridState, storage); - setStore(tGridStore); - } let browserFields = EMPTY_BROWSER_FIELDS; if ((tGridProps as TGridIntegratedProps).browserFields != null) { browserFields = (tGridProps as TGridIntegratedProps).browserFields; } return ( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - + diff --git a/x-pack/plugins/timelines/public/components/t_grid/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/index.tsx index 058261e6386c..7512edb77639 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/index.tsx @@ -9,13 +9,10 @@ import React from 'react'; import type { TGridProps } from '../../types'; import { TGridIntegrated, TGridIntegratedProps } from './integrated'; -import { TGridStandalone, TGridStandaloneProps } from './standalone'; export const TGrid = (props: TGridProps) => { const { type, ...componentsProps } = props; - if (type === 'standalone') { - return ; - } else if (type === 'embedded') { + if (type === 'embedded') { return ; } return null; diff --git a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx deleted file mode 100644 index 035027294f30..000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx +++ /dev/null @@ -1,394 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; -import { isEmpty } from 'lodash/fp'; -import React, { useEffect, useMemo } from 'react'; -import styled from 'styled-components'; -import { useDispatch } from 'react-redux'; -import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { Filter, Query } from '@kbn/es-query'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import type { CoreStart } from '@kbn/core/public'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { getEsQueryConfig } from '@kbn/data-plugin/common'; - -import type { Ecs } from '../../../../common/ecs'; -import { Direction, EntityType } from '../../../../common/search_strategy'; -import { TGridCellAction } from '../../../../common/types/timeline'; -import type { - CellValueElementProps, - ColumnHeaderOptions, - ControlColumnProps, - DataProvider, - RowRenderer, - SortColumnTable, - BulkActionsProp, - AlertStatus, -} from '../../../../common/types/timeline'; -import { useDeepEqualSelector } from '../../../hooks/use_selector'; -import { defaultHeaders } from '../body/column_headers/default_headers'; -import { getCombinedFilterQuery } from '../helpers'; -import { tGridActions, tGridSelectors } from '../../../store/t_grid'; -import type { State } from '../../../store/t_grid'; -import { useTimelineEvents } from '../../../container'; -import { StatefulBody } from '../body'; -import { LastUpdatedAt } from '../..'; -import { SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexItem, UpdatedFlexGroup } from '../styles'; -import { InspectButton, InspectButtonContainer } from '../../inspect'; -import { useFetchIndex } from '../../../container/source'; -import { TGridLoading, TGridEmpty, TableContext } from '../shared'; - -const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible: boolean }>` - overflow: hidden; - margin: 0; - display: ${({ $visible }) => ($visible ? 'flex' : 'none')}; -`; - -export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px -export const STANDALONE_ID = 'standalone-t-grid'; -const EMPTY_DATA_PROVIDERS: DataProvider[] = []; - -const TitleText = styled.span` - margin-right: 12px; -`; - -const AlertsTableWrapper = styled.div` - width: 100%; - height: 100%; -`; - -const EventsContainerLoading = styled.div.attrs(({ className = '' }) => ({ - className: `${SELECTOR_TIMELINE_GLOBAL_CONTAINER} ${className}`, -}))` - position: relative; - width: 100%; - overflow: hidden; - flex: 1; - display: flex; - flex-direction: column; -`; - -const ScrollableFlexItem = styled(EuiFlexItem)` - overflow: auto; -`; - -export interface TGridStandaloneProps { - columns: ColumnHeaderOptions[]; - dataViewId?: string | null; - defaultCellActions?: TGridCellAction[]; - deletedEventIds: Readonly; - disabledCellActions: string[]; - end: string; - entityType?: EntityType; - loadingText: React.ReactNode; - filters: Filter[]; - filterStatus?: AlertStatus; - getRowRenderer?: ({ - data, - rowRenderers, - }: { - data: Ecs; - rowRenderers: RowRenderer[]; - }) => RowRenderer | null; - hasAlertsCrudPermissions: ({ - ruleConsumer, - ruleProducer, - }: { - ruleConsumer: string; - ruleProducer?: string; - }) => boolean; - height?: number; - indexNames: string[]; - itemsPerPage?: number; - itemsPerPageOptions: number[]; - query: Query; - onRuleChange?: () => void; - onStateChange?: (state: State) => void; - renderCellValue: (props: CellValueElementProps) => React.ReactNode; - rowRenderers: RowRenderer[]; - runtimeMappings: MappingRuntimeFields; - setRefetch: (ref: () => void) => void; - start: string; - sort: SortColumnTable[]; - graphEventId?: string; - leadingControlColumns: ControlColumnProps[]; - trailingControlColumns: ControlColumnProps[]; - bulkActions?: BulkActionsProp; - data?: DataPublicPluginStart; - unit?: (total: number) => React.ReactNode; - showCheckboxes?: boolean; - queryFields?: string[]; -} - -const TGridStandaloneComponent: React.FC = ({ - columns, - dataViewId = null, - defaultCellActions, - deletedEventIds, - disabledCellActions, - end, - entityType = 'alerts', - loadingText, - filters, - filterStatus, - getRowRenderer, - hasAlertsCrudPermissions, - indexNames, - itemsPerPage, - itemsPerPageOptions, - onRuleChange, - query, - renderCellValue, - rowRenderers, - runtimeMappings, - setRefetch, - start, - sort, - graphEventId, - leadingControlColumns, - trailingControlColumns, - data, - unit, - showCheckboxes = true, - bulkActions = {}, - queryFields = [], -}) => { - const dispatch = useDispatch(); - const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; - const { uiSettings } = useKibana().services; - const [indexPatternsLoading, { browserFields, indexPatterns }] = useFetchIndex(indexNames); - - const getTGrid = useMemo(() => tGridSelectors.getTGridByIdSelector(), []); - const { - itemsPerPage: itemsPerPageStore, - itemsPerPageOptions: itemsPerPageOptionsStore, - queryFields: queryFieldsFromState, - sort: sortStore, - title, - } = useDeepEqualSelector((state) => getTGrid(state, STANDALONE_ID ?? '')); - - const justTitle = useMemo(() => {title}, [title]); - const esQueryConfig = getEsQueryConfig(uiSettings); - - const filterQuery = useMemo( - () => - getCombinedFilterQuery({ - config: esQueryConfig, - browserFields, - dataProviders: EMPTY_DATA_PROVIDERS, - filters, - from: start, - indexPattern: indexPatterns, - kqlMode: 'search', - kqlQuery: query, - to: end, - }), - [esQueryConfig, indexPatterns, browserFields, filters, start, end, query] - ); - - const canQueryTimeline = useMemo( - () => - filterQuery != null && - indexPatternsLoading != null && - !indexPatternsLoading && - !isEmpty(start) && - !isEmpty(end), - [indexPatternsLoading, filterQuery, start, end] - ); - - const fields = useMemo( - () => [ - ...columnsHeader.reduce( - (acc, c) => (c.linkField != null ? [...acc, c.id, c.linkField] : [...acc, c.id]), - [] - ), - ...(queryFieldsFromState ?? []), - ], - [columnsHeader, queryFieldsFromState] - ); - - const sortField = useMemo( - () => - sortStore.map(({ columnId, columnType, esTypes, sortDirection }) => ({ - field: columnId, - type: columnType, - direction: sortDirection as Direction, - esTypes: esTypes ?? [], - })), - [sortStore] - ); - - const [ - loading, - { consumers, events, updatedAt, loadPage, pageInfo, refetch, totalCount = 0, inspect }, - ] = useTimelineEvents({ - dataViewId, - entityType, - excludeEcsData: true, - fields, - filterQuery, - id: STANDALONE_ID, - indexNames, - limit: itemsPerPageStore, - runtimeMappings, - sort: sortField, - startDate: start, - endDate: end, - skip: !canQueryTimeline, - data, - }); - setRefetch(refetch); - - useEffect(() => { - dispatch(tGridActions.updateIsLoading({ id: STANDALONE_ID, isLoading: loading })); - }, [dispatch, loading]); - - const { hasAlertsCrud, totalSelectAllAlerts } = useMemo(() => { - return Object.entries(consumers).reduce<{ - hasAlertsCrud: boolean; - totalSelectAllAlerts: number; - }>( - (acc, [ruleConsumer, nbrAlerts]) => { - const featureHasPermission = hasAlertsCrudPermissions({ ruleConsumer }); - return { - hasAlertsCrud: featureHasPermission || acc.hasAlertsCrud, - totalSelectAllAlerts: featureHasPermission - ? nbrAlerts + acc.totalSelectAllAlerts - : acc.totalSelectAllAlerts, - }; - }, - { - hasAlertsCrud: false, - totalSelectAllAlerts: 0, - } - ); - }, [consumers, hasAlertsCrudPermissions]); - - const totalCountMinusDeleted = useMemo( - () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), - [deletedEventIds.length, totalCount] - ); - const hasAlerts = totalCountMinusDeleted > 0; - - // Only show the table-spanning loading indicator when the query is loading and we - // don't have data (e.g. for the initial fetch). - // Subsequent fetches (e.g. for pagination) will show a small loading indicator on - // top of the table and the table will display the current page until the next page - // is fetched. This prevents a flicker when paginating. - const showFullLoading = loading && !hasAlerts; - - const nonDeletedEvents = useMemo( - () => events.filter((e) => !deletedEventIds.includes(e._id)), - [deletedEventIds, events] - ); - - useEffect(() => { - dispatch( - tGridActions.createTGrid({ - id: STANDALONE_ID, - columns, - indexNames, - itemsPerPage: itemsPerPage || itemsPerPageStore, - itemsPerPageOptions, - showCheckboxes, - defaultColumns: columns, - sort, - }) - ); - dispatch( - tGridActions.initializeTGridSettings({ - id: STANDALONE_ID, - defaultColumns: columns, - sort, - loadingText, - unit, - queryFields, - }) - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const tableContext = { tableId: STANDALONE_ID }; - - // Clear checkbox selection when new events are fetched - useEffect(() => { - dispatch(tGridActions.clearSelected({ id: STANDALONE_ID })); - dispatch( - tGridActions.setTGridSelectAll({ - id: STANDALONE_ID, - selectAll: false, - }) - ); - }, [nonDeletedEvents, dispatch]); - - return ( - - - {showFullLoading && } - {canQueryTimeline ? ( - - - - - - - - - - - - {!hasAlerts && !loading && } - - {hasAlerts && ( - - - - - - )} - - - ) : null} - - - ); -}; - -export const TGridStandalone = React.memo(TGridStandaloneComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_bulk_actions.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_bulk_actions.tsx index ab77bd06e93e..f03c9802d44b 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_bulk_actions.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_bulk_actions.tsx @@ -127,7 +127,6 @@ export const AlertBulkActionsComponent = React.memo { - const { updateAlertStatus } = useUpdateAlertsStatus(scopeId !== STANDALONE_ID); + const { updateAlertStatus } = useUpdateAlertsStatus(true); const { addSuccess, addError, addWarning } = useAppToasts(); const { startTransaction } = useStartTransaction(); diff --git a/x-pack/plugins/timelines/public/methods/index.tsx b/x-pack/plugins/timelines/public/methods/index.tsx index 3605f07cc09a..92d6bc5a8bd3 100644 --- a/x-pack/plugins/timelines/public/methods/index.tsx +++ b/x-pack/plugins/timelines/public/methods/index.tsx @@ -49,7 +49,7 @@ export const getTGridLazy = ( ) => { initializeStore({ store, storage, setStore }); return ( - }> + }> ); diff --git a/x-pack/plugins/timelines/public/plugin.ts b/x-pack/plugins/timelines/public/plugin.ts index 91439d1928ac..27ca9de0cf03 100644 --- a/x-pack/plugins/timelines/public/plugin.ts +++ b/x-pack/plugins/timelines/public/plugin.ts @@ -6,8 +6,6 @@ */ import { Store, Unsubscribe } from 'redux'; -import { throttle } from 'lodash'; - import { Storage } from '@kbn/kibana-utils-plugin/public'; import type { CoreSetup, Plugin, CoreStart } from '@kbn/core/public'; import type { LastUpdatedAtProps, LoadingPanelProps } from './components'; @@ -40,20 +38,6 @@ export class TimelinesPlugin implements Plugin { } }, getTGrid: (props: TGridProps) => { - if (props.type === 'standalone' && this._store) { - const { getState } = this._store; - const state = getState(); - if (state && state.app) { - this._store = undefined; - } else { - if (props.onStateChange) { - this._storeUnsubscribe = this._store.subscribe( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - throttle(() => props.onStateChange!(getState()), 500) - ); - } - } - } return getTGridLazy(props, { store: this._store, storage: this._storage, diff --git a/x-pack/plugins/timelines/public/types.ts b/x-pack/plugins/timelines/public/types.ts index 66808ee40fb8..aec647934c2a 100644 --- a/x-pack/plugins/timelines/public/types.ts +++ b/x-pack/plugins/timelines/public/types.ts @@ -21,7 +21,6 @@ import type { } from './components'; export type { SortDirection } from '../common/types'; import type { TGridIntegratedProps } from './components/t_grid/integrated'; -import type { TGridStandaloneProps } from './components/t_grid/standalone'; import type { UseAddToTimelineProps, UseAddToTimeline } from './hooks/use_add_to_timeline'; import { HoverActionsConfig } from './components/hover_actions'; export * from './store/t_grid'; @@ -52,19 +51,14 @@ export interface TimelinesStartPlugins { } export type TimelinesStartServices = CoreStart & TimelinesStartPlugins; -interface TGridStandaloneCompProps extends TGridStandaloneProps { - type: 'standalone'; -} interface TGridIntegratedCompProps extends TGridIntegratedProps { type: 'embedded'; } -export type TGridType = 'standalone' | 'embedded'; -export type GetTGridProps = T extends 'standalone' - ? TGridStandaloneCompProps - : T extends 'embedded' +export type TGridType = 'embedded'; +export type GetTGridProps = T extends 'embedded' ? TGridIntegratedCompProps : TGridIntegratedCompProps; -export type TGridProps = TGridStandaloneCompProps | TGridIntegratedCompProps; +export type TGridProps = TGridIntegratedCompProps; export interface StatefulEventContextType { tabType: string | undefined; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index cb1903d4a961..0da5af908ac5 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -23587,7 +23587,6 @@ "xpack.observability.formatters.secondsTimeUnitLabel": "s", "xpack.observability.formatters.secondsTimeUnitLabelExtended": "secondes", "xpack.observability.home.addData": "Ajouter des intégrations", - "xpack.observability.hoverActions.filterForValue": "Filtrer sur la valeur", "xpack.observability.hoverActions.filterForValueButtonLabel": "Inclure", "xpack.observability.inspector.stats.dataViewDescription": "La vue de données qui se connecte aux index Elasticsearch.", "xpack.observability.inspector.stats.dataViewLabel": "Vue de données", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 81bd0f3d33b0..742cceebbd8c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23566,7 +23566,6 @@ "xpack.observability.formatters.secondsTimeUnitLabel": "s", "xpack.observability.formatters.secondsTimeUnitLabelExtended": "秒", "xpack.observability.home.addData": "統合の追加", - "xpack.observability.hoverActions.filterForValue": "値でフィルター", "xpack.observability.hoverActions.filterForValueButtonLabel": "フィルタリング", "xpack.observability.inspector.stats.dataViewDescription": "Elasticsearchインデックスに接続したデータビューです。", "xpack.observability.inspector.stats.dataViewLabel": "データビュー", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c1d8961ca766..d80ac7cc7ffe 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23597,7 +23597,6 @@ "xpack.observability.formatters.secondsTimeUnitLabel": "s", "xpack.observability.formatters.secondsTimeUnitLabelExtended": "秒", "xpack.observability.home.addData": "添加集成", - "xpack.observability.hoverActions.filterForValue": "筛留值", "xpack.observability.hoverActions.filterForValueButtonLabel": "筛选范围", "xpack.observability.inspector.stats.dataViewDescription": "连接到 Elasticsearch 索引的数据视图。", "xpack.observability.inspector.stats.dataViewLabel": "数据视图", diff --git a/x-pack/test/observability_functional/apps/observability/pages/alerts/index.ts b/x-pack/test/observability_functional/apps/observability/pages/alerts/index.ts index cdb0ea37a641..7052dcba7ff2 100644 --- a/x-pack/test/observability_functional/apps/observability/pages/alerts/index.ts +++ b/x-pack/test/observability_functional/apps/observability/pages/alerts/index.ts @@ -231,15 +231,6 @@ export default ({ getService }: FtrProviderContext) => { }); }); - /* - * ATTENTION FUTURE DEVELOPER - * - * These tests should only be valid for 7.17.x - * You can run this test if you go to this file: - * x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx - * and at line 397 and change showCheckboxes to true - * - */ describe.skip('Bulk Actions', () => { before(async () => { await security.testUser.setRoles(['global_alerts_logs_all_else_read']); diff --git a/x-pack/test/plugin_functional/config.ts b/x-pack/test/plugin_functional/config.ts index 361318c0992a..19846669f48b 100644 --- a/x-pack/test/plugin_functional/config.ts +++ b/x-pack/test/plugin_functional/config.ts @@ -31,7 +31,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { testFiles: [ resolve(__dirname, './test_suites/resolver'), resolve(__dirname, './test_suites/global_search'), - resolve(__dirname, './test_suites/timelines'), ], services, @@ -62,9 +61,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { resolverTest: { pathname: '/app/resolverTest', }, - timelineTest: { - pathname: '/app/timelinesTest', - }, }, // choose where screenshots should be saved diff --git a/x-pack/test/plugin_functional/plugins/timelines_test/kibana.json b/x-pack/test/plugin_functional/plugins/timelines_test/kibana.json deleted file mode 100644 index 1960c4983956..000000000000 --- a/x-pack/test/plugin_functional/plugins/timelines_test/kibana.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "timelinesTest", - "owner": { "name": "Security solution", "githubTeam": "security-solution" }, - "version": "1.0.0", - "kibanaVersion": "kibana", - "configPath": ["xpack", "timelinesTest"], - "requiredPlugins": ["timelines", "data"], - "requiredBundles": ["kibanaReact"], - "server": false, - "ui": true -} diff --git a/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx b/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx deleted file mode 100644 index 6b576012afb8..000000000000 --- a/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Router } from 'react-router-dom'; -import React, { useCallback, useRef } from 'react'; -import ReactDOM from 'react-dom'; -import { AppMountParameters, CoreStart } from '@kbn/core/public'; -import { I18nProvider } from '@kbn/i18n-react'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; -import { TimelinesUIStart } from '@kbn/timelines-plugin/public'; -import { DataPublicPluginStart } from '@kbn/data-plugin/public'; - -type CoreStartTimelines = CoreStart & { data: DataPublicPluginStart }; - -/** - * Render the Timeline Test app. Returns a cleanup function. - */ -export function renderApp( - coreStart: CoreStartTimelines, - parameters: AppMountParameters, - timelinesPluginSetup: TimelinesUIStart | null -) { - ReactDOM.render( - , - parameters.element - ); - - return () => { - ReactDOM.unmountComponentAtNode(parameters.element); - }; -} - -const AppRoot = React.memo( - ({ - coreStart, - parameters, - timelinesPluginSetup, - }: { - coreStart: CoreStartTimelines; - parameters: AppMountParameters; - timelinesPluginSetup: TimelinesUIStart | null; - }) => { - const refetch = useRef(); - - const setRefetch = useCallback((_refetch) => { - refetch.current = _refetch; - }, []); - - const hasAlertsCrudPermissions = useCallback(() => true, []); - - return ( - - - - - {(timelinesPluginSetup && - timelinesPluginSetup.getTGrid && - timelinesPluginSetup.getTGrid<'standalone'>({ - type: 'standalone', - columns: [], - indexNames: [], - deletedEventIds: [], - disabledCellActions: [], - end: '', - filters: [], - hasAlertsCrudPermissions, - itemsPerPageOptions: [1, 2, 3], - loadingText: 'Loading events', - renderCellValue: () =>
test
, - sort: [], - leadingControlColumns: [], - trailingControlColumns: [], - query: { - query: '', - language: 'kuery', - }, - setRefetch, - start: '', - rowRenderers: [], - runtimeMappings: {}, - filterStatus: 'open', - unit: (n: number) => `${n}`, - })) ?? - null} -
-
-
-
- ); - } -); diff --git a/x-pack/test/plugin_functional/plugins/timelines_test/public/index.ts b/x-pack/test/plugin_functional/plugins/timelines_test/public/index.ts deleted file mode 100644 index 540e23622b2b..000000000000 --- a/x-pack/test/plugin_functional/plugins/timelines_test/public/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { PluginInitializer } from '@kbn/core/public'; -import { - TimelinesTestPlugin, - TimelinesTestPluginSetupDependencies, - TimelinesTestPluginStartDependencies, -} from './plugin'; - -export const plugin: PluginInitializer< - void, - void, - TimelinesTestPluginSetupDependencies, - TimelinesTestPluginStartDependencies -> = () => new TimelinesTestPlugin(); diff --git a/x-pack/test/plugin_functional/plugins/timelines_test/public/plugin.ts b/x-pack/test/plugin_functional/plugins/timelines_test/public/plugin.ts deleted file mode 100644 index 13758e02603a..000000000000 --- a/x-pack/test/plugin_functional/plugins/timelines_test/public/plugin.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Plugin, CoreStart, CoreSetup, AppMountParameters } from '@kbn/core/public'; -import { i18n } from '@kbn/i18n'; -import { TimelinesUIStart } from '@kbn/timelines-plugin/public'; -import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { renderApp } from './applications/timelines_test'; - -export type TimelinesTestPluginSetup = void; -export type TimelinesTestPluginStart = void; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface TimelinesTestPluginSetupDependencies {} - -export interface TimelinesTestPluginStartDependencies { - timelines: TimelinesUIStart; - data: DataPublicPluginStart; -} - -export class TimelinesTestPlugin - implements - Plugin< - TimelinesTestPluginSetup, - void, - TimelinesTestPluginSetupDependencies, - TimelinesTestPluginStartDependencies - > -{ - private timelinesPlugin: TimelinesUIStart | null = null; - public setup( - core: CoreSetup, - setupDependencies: TimelinesTestPluginSetupDependencies - ) { - core.application.register({ - id: 'timelinesTest', - title: i18n.translate('xpack.timelinesTest.pluginTitle', { - defaultMessage: 'Timelines Test', - }), - mount: async (params: AppMountParameters) => { - const startServices = await core.getStartServices(); - const [coreStart, { data }] = startServices; - return renderApp({ ...coreStart, data }, params, this.timelinesPlugin); - }, - }); - } - - public start(core: CoreStart, { timelines }: TimelinesTestPluginStartDependencies) { - this.timelinesPlugin = timelines; - } -} diff --git a/x-pack/test/plugin_functional/test_suites/timelines/index.ts b/x-pack/test/plugin_functional/test_suites/timelines/index.ts deleted file mode 100644 index 955966eab12c..000000000000 --- a/x-pack/test/plugin_functional/test_suites/timelines/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function ({ getPageObjects, getService }: FtrProviderContext) { - describe('Timelines plugin API', function () { - const pageObjects = getPageObjects(['common']); - const testSubjects = getService('testSubjects'); - - describe('timelines plugin rendering', function () { - before(async () => { - await pageObjects.common.navigateToApp('timelineTest'); - }); - it('shows the timeline component on navigation', async () => { - await testSubjects.existOrFail('events-viewer-panel'); - }); - }); - }); -} From 5e7cd1032aa20ab94825953b28a5a54b7b686d82 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 20 Oct 2022 00:58:58 +0200 Subject: [PATCH 19/43] Fix flaky test 78553 (#143346) * updated comment and .only test * navigate to discover * Remove .only --- x-pack/test/functional/apps/discover/async_scripted_fields.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/discover/async_scripted_fields.js b/x-pack/test/functional/apps/discover/async_scripted_fields.js index ba670ad78aa3..9a9d5e0d450f 100644 --- a/x-pack/test/functional/apps/discover/async_scripted_fields.js +++ b/x-pack/test/functional/apps/discover/async_scripted_fields.js @@ -77,7 +77,7 @@ export default function ({ getService, getPageObjects }) { it('query return results with valid scripted field', async function () { if (false) { - /* the commented-out steps below were used to create the scripted fields in the logstash-* index pattern + /* the skipped steps below were used to create the scripted fields in the logstash-* index pattern which are now saved in the esArchive. */ @@ -118,6 +118,7 @@ export default function ({ getService, getPageObjects }) { }); } + await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.selectIndexPattern('logstash-*'); await queryBar.setQuery('php* OR *jpg OR *css*'); await testSubjects.click('querySubmitButton'); From 19b09ab635af73f16a4c2ffb416a4f7cbec45ea5 Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Wed, 19 Oct 2022 18:19:15 -0500 Subject: [PATCH 20/43] introduced detach ml inference (#143665) Added a DELETE endpoint that will delete the ml inference processors from the parent `@ml-inference` pipeline, but not delete the pipeline itself. This action will be needed to allow detaching a re-used pipeline without deleting the pipeline as it could still be used by other index pipelines. --- .../common/types/pipelines.ts | 10 ++ .../indices/delete_ml_inference_pipeline.ts | 74 ---------- .../delete_ml_inference_pipeline.test.ts | 0 .../delete_ml_inference_pipeline.ts | 37 +++++ .../detach_ml_inference_pipeline.test.ts | 139 ++++++++++++++++++ .../detach_ml_inference_pipeline.ts | 53 +++++++ .../routes/enterprise_search/indices.test.ts | 94 +++++++++++- .../routes/enterprise_search/indices.ts | 45 +++++- 8 files changed, 373 insertions(+), 79 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/server/lib/indices/delete_ml_inference_pipeline.ts rename x-pack/plugins/enterprise_search/server/lib/{indices => pipelines/ml_inference/pipeline_processors}/delete_ml_inference_pipeline.test.ts (100%) create mode 100644 x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.ts diff --git a/x-pack/plugins/enterprise_search/common/types/pipelines.ts b/x-pack/plugins/enterprise_search/common/types/pipelines.ts index b103b4a5265b..75bc44443e20 100644 --- a/x-pack/plugins/enterprise_search/common/types/pipelines.ts +++ b/x-pack/plugins/enterprise_search/common/types/pipelines.ts @@ -41,3 +41,13 @@ export interface MlInferenceError { doc_count: number; timestamp: string | undefined; // Date string } + +/** + * Response for deleting sub-pipeline from @ml-inference pipeline. + * If sub-pipeline was deleted successfully, 'deleted' field contains its name. + * If parent pipeline was updated successfully, 'updated' field contains its name. + */ +export interface DeleteMlInferencePipelineResponse { + deleted?: string; + updated?: string; +} diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/delete_ml_inference_pipeline.ts b/x-pack/plugins/enterprise_search/server/lib/indices/delete_ml_inference_pipeline.ts deleted file mode 100644 index 04a032e3be10..000000000000 --- a/x-pack/plugins/enterprise_search/server/lib/indices/delete_ml_inference_pipeline.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { IngestPutPipelineRequest } from '@elastic/elasticsearch/lib/api/types'; -import { ElasticsearchClient } from '@kbn/core/server'; - -import { getInferencePipelineNameFromIndexName } from '../../utils/ml_inference_pipeline_utils'; - -/** - * Response for deleting sub-pipeline from @ml-inference pipeline. - * If sub-pipeline was deleted successfully, 'deleted' field contains its name. - * If parent pipeline was updated successfully, 'updated' field contains its name. - */ -export interface DeleteMlInferencePipelineResponse { - deleted?: string; - updated?: string; -} - -export const deleteMlInferencePipeline = async ( - indexName: string, - pipelineName: string, - client: ElasticsearchClient -) => { - const response: DeleteMlInferencePipelineResponse = {}; - const parentPipelineId = getInferencePipelineNameFromIndexName(indexName); - - // find parent pipeline - try { - const pipelineResponse = await client.ingest.getPipeline({ - id: parentPipelineId, - }); - - const parentPipeline = pipelineResponse[parentPipelineId]; - - if (parentPipeline !== undefined) { - // remove sub-pipeline from parent pipeline - if (parentPipeline.processors !== undefined) { - const updatedProcessors = parentPipeline.processors.filter( - (p) => !(p.pipeline !== undefined && p.pipeline.name === pipelineName) - ); - // only update if we changed something - if (updatedProcessors.length !== parentPipeline.processors.length) { - const updatedPipeline: IngestPutPipelineRequest = { - ...parentPipeline, - id: parentPipelineId, - processors: updatedProcessors, - }; - - const updateResponse = await client.ingest.putPipeline(updatedPipeline); - if (updateResponse.acknowledged === true) { - response.updated = parentPipelineId; - } - } - } - } - } catch (error) { - // only suppress Not Found error - if (error.meta?.statusCode !== 404) { - throw error; - } - } - - // finally, delete pipeline - const deleteResponse = await client.ingest.deletePipeline({ id: pipelineName }); - if (deleteResponse.acknowledged === true) { - response.deleted = pipelineName; - } - - return response; -}; diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/delete_ml_inference_pipeline.test.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline.test.ts similarity index 100% rename from x-pack/plugins/enterprise_search/server/lib/indices/delete_ml_inference_pipeline.test.ts rename to x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline.test.ts diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline.ts new file mode 100644 index 000000000000..19654d0b2e93 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from '@kbn/core/server'; + +import { DeleteMlInferencePipelineResponse } from '../../../../../common/types/pipelines'; + +import { detachMlInferencePipeline } from './detach_ml_inference_pipeline'; + +export const deleteMlInferencePipeline = async ( + indexName: string, + pipelineName: string, + client: ElasticsearchClient +) => { + let response: DeleteMlInferencePipelineResponse = {}; + + try { + response = await detachMlInferencePipeline(indexName, pipelineName, client); + } catch (error) { + // only suppress Not Found error + if (error.meta?.statusCode !== 404) { + throw error; + } + } + + // finally, delete pipeline + const deleteResponse = await client.ingest.deletePipeline({ id: pipelineName }); + if (deleteResponse.acknowledged === true) { + response.deleted = pipelineName; + } + + return response; +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.test.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.test.ts new file mode 100644 index 000000000000..bcab516ab5b9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { errors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '@kbn/core/server'; + +import { detachMlInferencePipeline } from './detach_ml_inference_pipeline'; + +describe('detachMlInferencePipeline', () => { + const mockClient = { + ingest: { + deletePipeline: jest.fn(), + getPipeline: jest.fn(), + putPipeline: jest.fn(), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const anyObject: any = {}; + const notFoundResponse = { meta: { statusCode: 404 } }; + const notFoundError = new errors.ResponseError({ + body: notFoundResponse, + statusCode: 404, + headers: {}, + meta: anyObject, + warnings: [], + }); + const mockGetPipeline = { + 'my-index@ml-inference': { + id: 'my-index@ml-inference', + processors: [ + { + pipeline: { + name: 'my-ml-pipeline', + }, + }, + ], + }, + }; + + it('should update parent pipeline', async () => { + mockClient.ingest.getPipeline.mockImplementation(() => Promise.resolve(mockGetPipeline)); + mockClient.ingest.putPipeline.mockImplementation(() => Promise.resolve({ acknowledged: true })); + mockClient.ingest.deletePipeline.mockImplementation(() => + Promise.resolve({ acknowledged: true }) + ); + + const expectedResponse = { updated: 'my-index@ml-inference' }; + + const response = await detachMlInferencePipeline( + 'my-index', + 'my-ml-pipeline', + mockClient as unknown as ElasticsearchClient + ); + + expect(response).toEqual(expectedResponse); + + expect(mockClient.ingest.putPipeline).toHaveBeenCalledWith({ + id: 'my-index@ml-inference', + processors: [], + }); + expect(mockClient.ingest.deletePipeline).not.toHaveBeenCalledWith({ + id: 'my-ml-pipeline', + }); + }); + + it('should only remove provided pipeline from parent', async () => { + mockClient.ingest.getPipeline.mockImplementation(() => + Promise.resolve({ + 'my-index@ml-inference': { + id: 'my-index@ml-inference', + processors: [ + { + pipeline: { + name: 'my-ml-pipeline', + }, + }, + { + pipeline: { + name: 'my-ml-other-pipeline', + }, + }, + ], + }, + }) + ); + mockClient.ingest.putPipeline.mockImplementation(() => Promise.resolve({ acknowledged: true })); + mockClient.ingest.deletePipeline.mockImplementation(() => + Promise.resolve({ acknowledged: true }) + ); + + const expectedResponse = { updated: 'my-index@ml-inference' }; + + const response = await detachMlInferencePipeline( + 'my-index', + 'my-ml-pipeline', + mockClient as unknown as ElasticsearchClient + ); + + expect(response).toEqual(expectedResponse); + + expect(mockClient.ingest.putPipeline).toHaveBeenCalledWith({ + id: 'my-index@ml-inference', + processors: [ + { + pipeline: { + name: 'my-ml-other-pipeline', + }, + }, + ], + }); + expect(mockClient.ingest.deletePipeline).not.toHaveBeenCalledWith({ + id: 'my-ml-pipeline', + }); + }); + + it('should fail when parent pipeline is missing', async () => { + mockClient.ingest.getPipeline.mockImplementation(() => Promise.reject(notFoundError)); + + await expect( + detachMlInferencePipeline( + 'my-index', + 'my-ml-pipeline', + mockClient as unknown as ElasticsearchClient + ) + ).rejects.toThrow(Error); + + expect(mockClient.ingest.getPipeline).toHaveBeenCalledWith({ + id: 'my-index@ml-inference', + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.ts new file mode 100644 index 000000000000..02d6c328a8e4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IngestPutPipelineRequest } from '@elastic/elasticsearch/lib/api/types'; +import { ElasticsearchClient } from '@kbn/core/server'; + +import { DeleteMlInferencePipelineResponse } from '../../../../../common/types/pipelines'; + +import { getInferencePipelineNameFromIndexName } from '../../../../utils/ml_inference_pipeline_utils'; + +export const detachMlInferencePipeline = async ( + indexName: string, + pipelineName: string, + client: ElasticsearchClient +) => { + const response: DeleteMlInferencePipelineResponse = {}; + const parentPipelineId = getInferencePipelineNameFromIndexName(indexName); + + // find parent pipeline + const pipelineResponse = await client.ingest.getPipeline({ + id: parentPipelineId, + }); + + const parentPipeline = pipelineResponse[parentPipelineId]; + + if (parentPipeline !== undefined) { + // remove sub-pipeline from parent pipeline + if (parentPipeline.processors !== undefined) { + const updatedProcessors = parentPipeline.processors.filter( + (p) => !(p.pipeline !== undefined && p.pipeline.name === pipelineName) + ); + // only update if we changed something + if (updatedProcessors.length !== parentPipeline.processors.length) { + const updatedPipeline: IngestPutPipelineRequest = { + ...parentPipeline, + id: parentPipelineId, + processors: updatedProcessors, + }; + + const updateResponse = await client.ingest.putPipeline(updatedPipeline); + if (updateResponse.acknowledged === true) { + response.updated = parentPipelineId; + } + } + } + } + + return response; +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts index 371098e180a6..c2d23e5d2710 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts @@ -23,9 +23,18 @@ jest.mock('../../lib/indices/fetch_ml_inference_pipeline_processors', () => ({ jest.mock('../../utils/create_ml_inference_pipeline', () => ({ createAndReferenceMlInferencePipeline: jest.fn(), })); -jest.mock('../../lib/indices/delete_ml_inference_pipeline', () => ({ - deleteMlInferencePipeline: jest.fn(), -})); +jest.mock( + '../../lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline', + () => ({ + deleteMlInferencePipeline: jest.fn(), + }) +); +jest.mock( + '../../lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline', + () => ({ + detachMlInferencePipeline: jest.fn(), + }) +); jest.mock('../../lib/indices/exists_index', () => ({ indexOrAliasExists: jest.fn(), })); @@ -36,12 +45,13 @@ jest.mock('../../lib/ml_inference_pipeline/get_inference_pipelines', () => ({ getMlInferencePipelines: jest.fn(), })); -import { deleteMlInferencePipeline } from '../../lib/indices/delete_ml_inference_pipeline'; import { indexOrAliasExists } from '../../lib/indices/exists_index'; import { fetchMlInferencePipelineHistory } from '../../lib/indices/fetch_ml_inference_pipeline_history'; import { fetchMlInferencePipelineProcessors } from '../../lib/indices/fetch_ml_inference_pipeline_processors'; import { getMlInferenceErrors } from '../../lib/ml_inference_pipeline/get_inference_errors'; import { getMlInferencePipelines } from '../../lib/ml_inference_pipeline/get_inference_pipelines'; +import { deleteMlInferencePipeline } from '../../lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline'; +import { detachMlInferencePipeline } from '../../lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline'; import { createAndReferenceMlInferencePipeline } from '../../utils/create_ml_inference_pipeline'; import { ElasticsearchResponseError } from '../../utils/identify_exceptions'; @@ -647,6 +657,82 @@ describe('Enterprise Search Managed Indices', () => { }); }); + describe('DELETE /internal/enterprise_search/indices/{indexName}/ml_inference/pipeline_processors/{pipelineName}/detach', () => { + const indexName = 'my-index'; + const pipelineName = 'my-pipeline'; + + beforeEach(() => { + const context = { + core: Promise.resolve(mockCore), + } as unknown as jest.Mocked; + + mockRouter = new MockRouter({ + context, + method: 'delete', + path: '/internal/enterprise_search/indices/{indexName}/ml_inference/pipeline_processors/{pipelineName}/detach', + }); + + registerIndexRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('fails validation without index_name', () => { + const request = { params: {} }; + mockRouter.shouldThrow(request); + }); + + it('detaches pipeline', async () => { + const mockResponse = { updated: `${indexName}@ml-inference` }; + + (detachMlInferencePipeline as jest.Mock).mockImplementationOnce(() => { + return Promise.resolve(mockResponse); + }); + + await mockRouter.callRoute({ + params: { indexName, pipelineName }, + }); + + expect(detachMlInferencePipeline).toHaveBeenCalledWith( + indexName, + pipelineName, + mockClient.asCurrentUser + ); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: mockResponse, + headers: { 'content-type': 'application/json' }, + }); + }); + + it('raises error if detaching failed', async () => { + const errorReason = `pipeline is missing: [${pipelineName}]`; + const mockError = new Error(errorReason) as ElasticsearchResponseError; + mockError.meta = { + body: { + error: { + type: 'resource_not_found_exception', + }, + }, + }; + (detachMlInferencePipeline as jest.Mock).mockImplementationOnce(() => { + return Promise.reject(mockError); + }); + + await mockRouter.callRoute({ + params: { indexName, pipelineName }, + }); + + expect(detachMlInferencePipeline).toHaveBeenCalledWith( + indexName, + pipelineName, + mockClient.asCurrentUser + ); + expect(mockRouter.response.customError).toHaveBeenCalledTimes(1); + }); + }); + describe('GET /internal/enterprise_search/pipelines/ml_inference', () => { let mockTrainedModelsProvider: MlTrainedModels; let mockMl: SharedServices; diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts index a8dd969376b7..1ff78028beaa 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts @@ -22,7 +22,6 @@ import { fetchConnectorByIndexName, fetchConnectors } from '../../lib/connectors import { fetchCrawlerByIndexName, fetchCrawlers } from '../../lib/crawler/fetch_crawlers'; import { createIndex } from '../../lib/indices/create_index'; -import { deleteMlInferencePipeline } from '../../lib/indices/delete_ml_inference_pipeline'; import { indexOrAliasExists } from '../../lib/indices/exists_index'; import { fetchIndex } from '../../lib/indices/fetch_index'; import { fetchIndices } from '../../lib/indices/fetch_indices'; @@ -34,6 +33,8 @@ import { getMlInferencePipelines } from '../../lib/ml_inference_pipeline/get_inf import { createIndexPipelineDefinitions } from '../../lib/pipelines/create_pipeline_definitions'; import { getCustomPipelines } from '../../lib/pipelines/get_custom_pipelines'; import { getPipeline } from '../../lib/pipelines/get_pipeline'; +import { deleteMlInferencePipeline } from '../../lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline'; +import { detachMlInferencePipeline } from '../../lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline'; import { RouteDependencies } from '../../plugin'; import { createError } from '../../utils/create_error'; import { @@ -704,4 +705,46 @@ export function registerIndexRoutes({ }); }) ); + + router.delete( + { + path: '/internal/enterprise_search/indices/{indexName}/ml_inference/pipeline_processors/{pipelineName}/detach', + validate: { + params: schema.object({ + indexName: schema.string(), + pipelineName: schema.string(), + }), + }, + }, + elasticsearchErrorHandler(log, async (context, request, response) => { + const indexName = decodeURIComponent(request.params.indexName); + const pipelineName = decodeURIComponent(request.params.pipelineName); + const { client } = (await context.core).elasticsearch; + + try { + const detachResult = await detachMlInferencePipeline( + indexName, + pipelineName, + client.asCurrentUser + ); + + return response.ok({ + body: detachResult, + headers: { 'content-type': 'application/json' }, + }); + } catch (error) { + if (isResourceNotFoundException(error)) { + // return specific message if pipeline doesn't exist + return createError({ + errorCode: ErrorCode.RESOURCE_NOT_FOUND, + message: error.meta?.body?.error?.reason, + response, + statusCode: 404, + }); + } + // otherwise, let the default handler wrap it + throw error; + } + }) + ); } From 8b8c1fc61e80d194f6e54f3961b4bb5ff10003ae Mon Sep 17 00:00:00 2001 From: Brian McGue Date: Wed, 19 Oct 2022 16:34:57 -0700 Subject: [PATCH 21/43] Add transformations for text_embedding and text_classification (#143603) * Add transformations for text_embedding and text_classification * Better expectation in jest test * Change type import. * Update types * Fix tests * Hack to workaround ml-plugin constant import issue --- .../ml_inference_pipeline/index.test.ts | 174 ++++++++++++++++-- .../common/ml_inference_pipeline/index.ts | 49 ++++- .../types/@elastic/elasticsearch/index.d.ts | 20 ++ .../lib/pipelines/get_custom_pipelines.ts | 2 +- .../server/lib/pipelines/get_pipeline.ts | 2 +- .../create_ml_inference_pipeline.test.ts | 3 + .../utils/create_ml_inference_pipeline.ts | 12 +- 7 files changed, 238 insertions(+), 24 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/common/types/@elastic/elasticsearch/index.d.ts diff --git a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts index 29becfa3e99a..538d8016a0a7 100644 --- a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts +++ b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts @@ -5,23 +5,34 @@ * 2.0. */ -import { MlTrainedModelConfig } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IngestSetProcessor, MlTrainedModelConfig } from '@elastic/elasticsearch/lib/api/types'; import { BUILT_IN_MODEL_TAG } from '@kbn/ml-plugin/common/constants/data_frame_analytics'; +import { SUPPORTED_PYTORCH_TASKS } from '@kbn/ml-plugin/common/constants/trained_models'; -import { getMlModelTypesForModelConfig, BUILT_IN_MODEL_TAG as LOCAL_BUILT_IN_MODEL_TAG } from '.'; +import { MlInferencePipeline } from '../types/pipelines'; + +import { + BUILT_IN_MODEL_TAG as LOCAL_BUILT_IN_MODEL_TAG, + generateMlInferencePipelineBody, + getMlModelTypesForModelConfig, + getSetProcessorForInferenceType, + SUPPORTED_PYTORCH_TASKS as LOCAL_SUPPORTED_PYTORCH_TASKS, +} from '.'; + +const mockModel: MlTrainedModelConfig = { + inference_config: { + ner: {}, + }, + input: { + field_names: [], + }, + model_id: 'test_id', + model_type: 'pytorch', + tags: ['test_tag'], + version: '1', +}; describe('getMlModelTypesForModelConfig lib function', () => { - const mockModel: MlTrainedModelConfig = { - inference_config: { - ner: {}, - }, - input: { - field_names: [], - }, - model_id: 'test_id', - model_type: 'pytorch', - tags: ['test_tag'], - }; const builtInMockModel: MlTrainedModelConfig = { inference_config: { text_classification: {}, @@ -50,3 +61,140 @@ describe('getMlModelTypesForModelConfig lib function', () => { expect(LOCAL_BUILT_IN_MODEL_TAG).toEqual(BUILT_IN_MODEL_TAG); }); }); + +describe('getSetProcessorForInferenceType lib function', () => { + const destinationField = 'dest'; + + it('local LOCAL_SUPPORTED_PYTORCH_TASKS matches ml plugin', () => { + expect(SUPPORTED_PYTORCH_TASKS).toEqual(LOCAL_SUPPORTED_PYTORCH_TASKS); + }); + + it('should return expected value for TEXT_CLASSIFICATION', () => { + const inferenceType = SUPPORTED_PYTORCH_TASKS.TEXT_CLASSIFICATION; + + const expected: IngestSetProcessor = { + copy_from: 'ml.inference.dest.predicted_value', + description: + "Copy the predicted_value to 'dest' if the prediction_probability is greater than 0.5", + field: destinationField, + if: 'ml.inference.dest.prediction_probability > 0.5', + value: undefined, + }; + + expect(getSetProcessorForInferenceType(destinationField, inferenceType)).toEqual(expected); + }); + + it('should return expected value for TEXT_EMBEDDING', () => { + const inferenceType = SUPPORTED_PYTORCH_TASKS.TEXT_EMBEDDING; + + const expected: IngestSetProcessor = { + copy_from: 'ml.inference.dest.predicted_value', + description: "Copy the predicted_value to 'dest'", + field: destinationField, + value: undefined, + }; + + expect(getSetProcessorForInferenceType(destinationField, inferenceType)).toEqual(expected); + }); + + it('should return undefined for unknown inferenceType', () => { + const inferenceType = 'wrongInferenceType'; + + expect(getSetProcessorForInferenceType(destinationField, inferenceType)).toBeUndefined(); + }); +}); + +describe('generateMlInferencePipelineBody lib function', () => { + const expected: MlInferencePipeline = { + description: 'my-description', + processors: [ + { + remove: { + field: 'ml.inference.my-destination-field', + ignore_missing: true, + }, + }, + { + inference: { + field_map: { + 'my-source-field': 'MODEL_INPUT_FIELD', + }, + model_id: 'test_id', + on_failure: [ + { + append: { + field: '_source._ingest.inference_errors', + value: [ + { + message: + "Processor 'inference' in pipeline 'my-pipeline' failed with message '{{ _ingest.on_failure_message }}'", + pipeline: 'my-pipeline', + timestamp: '{{{ _ingest.timestamp }}}', + }, + ], + }, + }, + ], + target_field: 'ml.inference.my-destination-field', + }, + }, + { + append: { + field: '_source._ingest.processors', + value: [ + { + model_version: '1', + pipeline: 'my-pipeline', + processed_timestamp: '{{{ _ingest.timestamp }}}', + types: ['pytorch', 'ner'], + }, + ], + }, + }, + ], + version: 1, + }; + + it('should return something expected', () => { + const actual: MlInferencePipeline = generateMlInferencePipelineBody({ + description: 'my-description', + destinationField: 'my-destination-field', + model: mockModel, + pipelineName: 'my-pipeline', + sourceField: 'my-source-field', + }); + + expect(actual).toEqual(expected); + }); + + it('should return something expected 2', () => { + const mockTextClassificationModel: MlTrainedModelConfig = { + ...mockModel, + ...{ inference_config: { text_classification: {} } }, + }; + const actual: MlInferencePipeline = generateMlInferencePipelineBody({ + description: 'my-description', + destinationField: 'my-destination-field', + model: mockTextClassificationModel, + pipelineName: 'my-pipeline', + sourceField: 'my-source-field', + }); + + expect(actual).toEqual( + expect.objectContaining({ + description: expect.any(String), + processors: expect.arrayContaining([ + expect.objectContaining({ + set: { + copy_from: 'ml.inference.my-destination-field.predicted_value', + description: + "Copy the predicted_value to 'my-destination-field' if the prediction_probability is greater than 0.5", + field: 'my-destination-field', + if: 'ml.inference.my-destination-field.prediction_probability > 0.5', + }, + }), + ]), + }) + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts index 00d893ba9aba..b5b4526d1723 100644 --- a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts +++ b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { MlTrainedModelConfig } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IngestSetProcessor, MlTrainedModelConfig } from '@elastic/elasticsearch/lib/api/types'; import { MlInferencePipeline } from '../types/pipelines'; @@ -13,6 +13,17 @@ import { MlInferencePipeline } from '../types/pipelines'; // So defining it locally for now with a test to make sure it matches. export const BUILT_IN_MODEL_TAG = 'prepackaged'; +// Getting an error importing this from @kbn/ml-plugin/common/constants/trained_models' +// So defining it locally for now with a test to make sure it matches. +export const SUPPORTED_PYTORCH_TASKS = { + FILL_MASK: 'fill_mask', + NER: 'ner', + QUESTION_ANSWERING: 'question_answering', + TEXT_CLASSIFICATION: 'text_classification', + TEXT_EMBEDDING: 'text_embedding', + ZERO_SHOT_CLASSIFICATION: 'zero_shot_classification', +} as const; + export interface MlInferencePipelineParams { description?: string; destinationField: string; @@ -36,6 +47,10 @@ export const generateMlInferencePipelineBody = ({ // if model returned no input field, insert a placeholder const modelInputField = model.input?.field_names?.length > 0 ? model.input.field_names[0] : 'MODEL_INPUT_FIELD'; + + const inferenceType = Object.keys(model.inference_config)[0]; + const set = getSetProcessorForInferenceType(destinationField, inferenceType); + return { description: description ?? '', processors: [ @@ -51,21 +66,21 @@ export const generateMlInferencePipelineBody = ({ [sourceField]: modelInputField, }, model_id: model.model_id, - target_field: `ml.inference.${destinationField}`, on_failure: [ { append: { field: '_source._ingest.inference_errors', value: [ { - pipeline: pipelineName, message: `Processor 'inference' in pipeline '${pipelineName}' failed with message '{{ _ingest.on_failure_message }}'`, + pipeline: pipelineName, timestamp: '{{{ _ingest.timestamp }}}', }, ], }, }, ], + target_field: `ml.inference.${destinationField}`, }, }, { @@ -81,11 +96,39 @@ export const generateMlInferencePipelineBody = ({ ], }, }, + ...(set ? [{ set }] : []), ], version: 1, }; }; +export const getSetProcessorForInferenceType = ( + destinationField: string, + inferenceType: string +): IngestSetProcessor | undefined => { + let set: IngestSetProcessor | undefined; + const prefixedDestinationField = `ml.inference.${destinationField}`; + + if (inferenceType === SUPPORTED_PYTORCH_TASKS.TEXT_CLASSIFICATION) { + set = { + copy_from: `${prefixedDestinationField}.predicted_value`, + description: `Copy the predicted_value to '${destinationField}' if the prediction_probability is greater than 0.5`, + field: destinationField, + if: `${prefixedDestinationField}.prediction_probability > 0.5`, + value: undefined, + }; + } else if (inferenceType === SUPPORTED_PYTORCH_TASKS.TEXT_EMBEDDING) { + set = { + copy_from: `${prefixedDestinationField}.predicted_value`, + description: `Copy the predicted_value to '${destinationField}'`, + field: destinationField, + value: undefined, + }; + } + + return set; +}; + /** * Parses model types list from the given configuration of a trained machine learning model * @param trainedModel configuration for a trained machine learning model diff --git a/x-pack/plugins/enterprise_search/common/types/@elastic/elasticsearch/index.d.ts b/x-pack/plugins/enterprise_search/common/types/@elastic/elasticsearch/index.d.ts new file mode 100644 index 000000000000..05227a54bfb5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/types/@elastic/elasticsearch/index.d.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '@elastic/elasticsearch/lib/api/types'; + +// TODO: Remove once type fixed in elasticsearch-specification +// (add github issue) +declare module '@elastic/elasticsearch/lib/api/types' { + // This workaround adds copy_from and description to the original IngestSetProcess and makes value + // optional. It should be value xor copy_from, but that requires using type unions. This + // workaround requires interface merging (ie, not types), so we cannot get. + export interface IngestSetProcessor { + copy_from?: string; + description?: string; + } +} diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/get_custom_pipelines.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_custom_pipelines.ts index 11127e7e5d23..d7f2dd2bbab2 100644 --- a/x-pack/plugins/enterprise_search/server/lib/pipelines/get_custom_pipelines.ts +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_custom_pipelines.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IngestGetPipelineResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IngestGetPipelineResponse } from '@elastic/elasticsearch/lib/api/types'; import { IScopedClusterClient } from '@kbn/core/server'; export const getCustomPipelines = async ( diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/get_pipeline.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_pipeline.ts index a02b4cdd8b19..05b83d88a0b1 100644 --- a/x-pack/plugins/enterprise_search/server/lib/pipelines/get_pipeline.ts +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_pipeline.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IngestGetPipelineResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IngestGetPipelineResponse } from '@elastic/elasticsearch/lib/api/types'; import { IScopedClusterClient } from '@kbn/core/server'; export const getPipeline = async ( diff --git a/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.test.ts b/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.test.ts index d3aa24560594..47a4676e5926 100644 --- a/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.test.ts +++ b/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.test.ts @@ -37,6 +37,9 @@ describe('createMlInferencePipeline util function', () => { Promise.resolve({ trained_model_configs: [ { + inference_config: { + ner: {}, + }, input: { field_names: ['target-field'], }, diff --git a/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.ts b/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.ts index da4bce12d71e..e0b4d903be2a 100644 --- a/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.ts +++ b/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.ts @@ -22,9 +22,9 @@ import { * Details of a created pipeline. */ export interface CreatedPipeline { - id: string; - created?: boolean; addedToParentPipeline?: boolean; + created?: boolean; + id: string; } /** @@ -110,8 +110,8 @@ export const createMlInferencePipeline = async ( }); return Promise.resolve({ - id: inferencePipelineGeneratedName, created: true, + id: inferencePipelineGeneratedName, }); }; @@ -143,8 +143,8 @@ export const addSubPipelineToIndexSpecificMlPipeline = async ( // Verify the parent pipeline exists with a processors array if (!parentPipeline?.processors) { return Promise.resolve({ - id: pipelineName, addedToParentPipeline: false, + id: pipelineName, }); } @@ -155,8 +155,8 @@ export const addSubPipelineToIndexSpecificMlPipeline = async ( ); if (existingSubPipeline) { return Promise.resolve({ - id: pipelineName, addedToParentPipeline: false, + id: pipelineName, }); } @@ -173,7 +173,7 @@ export const addSubPipelineToIndexSpecificMlPipeline = async ( }); return Promise.resolve({ - id: pipelineName, addedToParentPipeline: true, + id: pipelineName, }); }; From 1d7c0842b7115ebf56379a5fcbd8a3ed851cb1b4 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Wed, 19 Oct 2022 17:26:10 -0700 Subject: [PATCH 22/43] Skip flaky test #143718 Signed-off-by: Tyler Smalley --- .../public/timelines/components/timeline/body/index.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index d6297d1f0b3f..cffa38e3435b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -129,7 +129,8 @@ jest.mock('../../fields_browser/create_field_button', () => ({ useCreateFieldButton: () => <>, })); -describe('Body', () => { +// SKIP: https://github.com/elastic/kibana/issues/143718 +describe.skip('Body', () => { const mount = useMountAppended(); const mockRefetch = jest.fn(); let appToastsMock: jest.Mocked>; From 3799ed3e19d5b1bffa4941bca3523a4635d69cae Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Thu, 20 Oct 2022 10:26:42 +0300 Subject: [PATCH 23/43] [Lens][TSVB] Add support of units for metric converted to formula (#143625) * Add support of units in formula * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * Removed unneeded imports Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../public/convert_to_lens/lib/convert/column.ts | 11 +++++++++-- .../public/convert_to_lens/lib/convert/formula.ts | 2 +- .../convert_to_lens/lib/metrics/metrics_helpers.ts | 5 ++++- .../convert_to_lens/lib/metrics/pipeline_formula.ts | 5 +++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/column.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/column.ts index bd0a9d572f19..c06cc3e72227 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/column.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/column.ts @@ -27,6 +27,7 @@ interface ExtraColumnFields { isSplit?: boolean; reducedTimeRange?: string; timeShift?: string; + isAssignTimeScale?: boolean; } const isSupportedFormat = (format: string) => ['bytes', 'number', 'percent'].includes(format); @@ -56,7 +57,13 @@ export const createColumn = ( series: Series, metric: Metric, field?: DataViewField, - { isBucketed = false, isSplit = false, reducedTimeRange, timeShift }: ExtraColumnFields = {} + { + isBucketed = false, + isSplit = false, + reducedTimeRange, + timeShift, + isAssignTimeScale = true, + }: ExtraColumnFields = {} ): GeneralColumnWithMeta => ({ columnId: uuid(), dataType: (field?.type as DataType) ?? undefined, @@ -66,7 +73,7 @@ export const createColumn = ( reducedTimeRange, filter: series.filter, timeShift, - timeScale: getTimeScale(metric), + timeScale: isAssignTimeScale ? getTimeScale(metric) : undefined, meta: { metricId: metric.id }, }); diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.ts index 15b35fade92c..91d887f6a668 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.ts @@ -38,7 +38,7 @@ export const createFormulaColumn = ( return { operationType: 'formula', references: [], - ...createColumn(series, metric), + ...createColumn(series, metric, undefined, { isAssignTimeScale: false }), params: { ...params, ...getFormat(series) }, }; }; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/metrics_helpers.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/metrics_helpers.ts index 8d970f2f7262..4b679d0dd0d1 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/metrics_helpers.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/metrics_helpers.ts @@ -133,10 +133,13 @@ export const getFormulaEquivalent = ( }${addAdditionalArgs({ reducedTimeRange, timeShift })})`; } case 'positive_rate': { - return buildCounterRateFormula(aggFormula, currentMetric.field!, { + const counterRateFormula = buildCounterRateFormula(aggFormula, currentMetric.field!, { reducedTimeRange, timeShift, }); + return currentMetric.unit + ? `normalize_by_unit(${counterRateFormula}, unit='${getTimeScale(currentMetric)}')` + : counterRateFormula; } case 'filter_ratio': { return getFilterRatioFormula(currentMetric, { reducedTimeRange, timeShift }); diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/pipeline_formula.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/pipeline_formula.ts index d357d886ff5b..bc4c8eea11ba 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/pipeline_formula.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/pipeline_formula.ts @@ -9,7 +9,7 @@ import { TSVB_METRIC_TYPES } from '../../../../common/enums'; import type { Metric } from '../../../../common/types'; import { getFormulaFromMetric, SUPPORTED_METRICS } from './supported_metrics'; -import { getFormulaEquivalent } from './metrics_helpers'; +import { getFormulaEquivalent, getTimeScale } from './metrics_helpers'; const getAdditionalArgs = (metric: Metric) => { if (metric.type === TSVB_METRIC_TYPES.POSITIVE_ONLY) { @@ -54,5 +54,6 @@ export const getPipelineSeriesFormula = ( } const additionalArgs = getAdditionalArgs(metric); - return `${aggFormula}(${subFormula}${additionalArgs})`; + const formula = `${aggFormula}(${subFormula}${additionalArgs})`; + return metric.unit ? `normalize_by_unit(${formula}, unit='${getTimeScale(metric)}')` : formula; }; From 81893484b6dcd4287601257af3f2a920abb30737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Thu, 20 Oct 2022 10:04:01 +0200 Subject: [PATCH 24/43] [Guided onboarding] Update urls for the Security guide (#143680) --- .../public/constants/guides_config/security.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/plugins/guided_onboarding/public/constants/guides_config/security.ts b/src/plugins/guided_onboarding/public/constants/guides_config/security.ts index 68ebc849f94c..d2f9b352b9d8 100644 --- a/src/plugins/guided_onboarding/public/constants/guides_config/security.ts +++ b/src/plugins/guided_onboarding/public/constants/guides_config/security.ts @@ -45,6 +45,10 @@ export const securityConfig: GuideConfig = { description: 'Mark the step complete by opening the panel and clicking the button "Mark done"', }, + location: { + appID: 'securitySolutionUI', + path: '/rules', + }, }, { id: 'alertsCases', @@ -54,6 +58,10 @@ export const securityConfig: GuideConfig = { 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', 'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.', ], + location: { + appID: 'securitySolutionUI', + path: '/alerts', + }, }, ], }; From dba19ee06ed51aed046d00d40ca8f6280e84a483 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 20 Oct 2022 10:37:39 +0200 Subject: [PATCH 25/43] [Exploratory view] Use series names as labels (#143458) --- x-pack/plugins/observability/kibana.json | 3 +- .../configurations/lens_attributes.test.ts | 12 +++-- .../configurations/lens_attributes.ts | 46 +++++++++++++------ .../test_data/mobile_test_attribute.ts | 10 +++- .../test_data/sample_attribute.ts | 4 +- .../test_data/sample_attribute_cwv.ts | 2 + .../test_data/sample_attribute_kpi.ts | 4 +- .../sample_attribute_with_reference_lines.ts | 4 +- .../series_editor/columns/series_name.tsx | 2 + .../apps/observability/exploratory_view.ts | 12 ++++- 10 files changed, 73 insertions(+), 26 deletions(-) diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index deb8859002bc..43e69c1b4d95 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -43,7 +43,8 @@ "kibanaReact", "kibanaUtils", "lens", - "usageCollection" + "usageCollection", + "visualizations" ], "extraPublicDirs": [ "common" diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index b031bff08b0f..d1fdd6bc7cf6 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -158,7 +158,7 @@ describe('Lens Attribute', () => { customLabel: true, dataType: 'number', isBucketed: false, - label: 'Pages loaded', + label: 'test-series', operationType: 'formula', params: { format: { @@ -427,7 +427,13 @@ describe('Lens Attribute', () => { ], }, ], - legend: { isVisible: true, showSingleSeries: true, position: 'right' }, + legend: { + isVisible: true, + showSingleSeries: true, + position: 'right', + legendSize: 'large', + shouldTruncate: false, + }, preferredSeriesType: 'line', tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, valueLabels: 'hide', @@ -545,7 +551,7 @@ describe('Lens Attribute', () => { 'transaction.type: page-load and processor.event: transaction and transaction.type : *', }, isBucketed: false, - label: 'Pages loaded', + label: 'test-series', operationType: 'formula', params: { format: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index 8e39ff3bdd2c..60f554d5344c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -37,6 +37,7 @@ import { } from '@kbn/lens-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; import { PersistableFilter } from '@kbn/lens-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { urlFiltersToKueryString } from '../utils/stringify_kueries'; import { FILTER_RECORDS, @@ -397,15 +398,14 @@ export class LensAttributes { return { ...buildNumberColumn(sourceField), label: - operationType === 'unique_count' || shortLabel - ? label || seriesConfig.labels[sourceField] - : i18n.translate('xpack.observability.expView.columns.operation.label', { - defaultMessage: '{operationType} of {sourceField}', - values: { - sourceField: label || seriesConfig.labels[sourceField], - operationType: capitalize(operationType), - }, - }), + label ?? + i18n.translate('xpack.observability.expView.columns.operation.label', { + defaultMessage: '{operationType} of {sourceField}', + values: { + sourceField: seriesConfig.labels[sourceField], + operationType: capitalize(operationType), + }, + }), filter: columnFilter, operationType, params: @@ -574,7 +574,7 @@ export class LensAttributes { const { type: fieldType } = fieldMeta ?? {}; if (columnType === TERMS_COLUMN) { - return this.getTermsColumn(fieldName, columnLabel || label); + return this.getTermsColumn(fieldName, label || columnLabel); } if (fieldName === RECORDS_FIELD || columnType === FILTER_RECORDS) { @@ -606,7 +606,7 @@ export class LensAttributes { columnType, columnFilter: columnFilters?.[0], operationType, - label: columnLabel || label, + label: label || columnLabel, seriesConfig: layerConfig.seriesConfig, shortLabel, }); @@ -615,7 +615,7 @@ export class LensAttributes { return this.getNumberOperationColumn({ sourceField: fieldName, operationType: 'unique_count', - label: columnLabel || label, + label: label || columnLabel, seriesConfig: layerConfig.seriesConfig, columnFilter: columnFilters?.[0], }); @@ -687,8 +687,18 @@ export class LensAttributes { getMainYAxis(layerConfig: LayerConfig, layerId: string, columnFilter: string) { const { breakdown } = layerConfig; - const { sourceField, operationType, label, timeScale } = - layerConfig.seriesConfig.yAxisColumns[0]; + const { + sourceField, + operationType, + label: colLabel, + timeScale, + } = layerConfig.seriesConfig.yAxisColumns[0]; + + let label = layerConfig.name || colLabel; + + if (layerConfig.seriesConfig.reportType === ReportTypes.CORE_WEB_VITAL) { + label = colLabel; + } if (sourceField === RECORDS_PERCENTAGE_FIELD) { return [ @@ -1028,7 +1038,13 @@ export class LensAttributes { getXyState(): XYState { return { - legend: { isVisible: true, showSingleSeries: true, position: 'right' }, + legend: { + isVisible: true, + showSingleSeries: true, + position: 'right', + legendSize: LegendSize.LARGE, + shouldTruncate: false, + }, valueLabels: 'hide', fittingFunction: 'Linear', curveType: 'CURVE_MONOTONE_X' as XYCurveType, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/mobile_test_attribute.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/mobile_test_attribute.ts index 85fd0c9f601b..1af87c385d31 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/mobile_test_attribute.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/mobile_test_attribute.ts @@ -39,7 +39,7 @@ export const testMobileKPIAttr = { }, 'y-axis-column-layer0-0': { isBucketed: false, - label: 'Median of System memory usage', + label: 'test-series', operationType: 'median', params: {}, scale: 'ratio', @@ -58,7 +58,13 @@ export const testMobileKPIAttr = { }, }, visualization: { - legend: { isVisible: true, showSingleSeries: true, position: 'right' }, + legend: { + isVisible: true, + showSingleSeries: true, + position: 'right', + legendSize: 'large', + shouldTruncate: false, + }, valueLabels: 'hide', fittingFunction: 'Linear', curveType: 'CURVE_MONOTONE_X', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts index 6c6424a0362d..4661775b3a83 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts @@ -67,7 +67,7 @@ export const sampleAttribute = { 'transaction.type: page-load and processor.event: transaction and transaction.type : *', }, isBucketed: false, - label: 'Pages loaded', + label: 'test-series', operationType: 'formula', params: { format: { @@ -322,6 +322,8 @@ export const sampleAttribute = { isVisible: true, position: 'right', showSingleSeries: true, + legendSize: 'large', + shouldTruncate: false, }, preferredSeriesType: 'line', tickLabelsVisibilitySettings: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts index 1cf945c4456a..15e462c10be2 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts @@ -141,6 +141,8 @@ export const sampleAttributeCoreWebVital = { isVisible: true, showSingleSeries: true, position: 'right', + shouldTruncate: false, + legendSize: 'large', }, preferredSeriesType: 'line', tickLabelsVisibilitySettings: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts index 280438737b5d..648279519889 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts @@ -45,7 +45,7 @@ export const sampleAttributeKpi = { query: 'transaction.type: page-load and processor.event: transaction', }, isBucketed: false, - label: 'Page views', + label: 'test-series', operationType: 'count', scale: 'ratio', sourceField: RECORDS_FIELD, @@ -95,6 +95,8 @@ export const sampleAttributeKpi = { isVisible: true, showSingleSeries: true, position: 'right', + legendSize: 'large', + shouldTruncate: false, }, preferredSeriesType: 'line', tickLabelsVisibilitySettings: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_with_reference_lines.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_with_reference_lines.ts index 5d51b1c19340..873e4a6269de 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_with_reference_lines.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_with_reference_lines.ts @@ -67,7 +67,7 @@ export const sampleAttributeWithReferenceLines = { 'transaction.type: page-load and processor.event: transaction and transaction.type : * and service.name: (elastic or kibana)', }, isBucketed: false, - label: 'Pages loaded', + label: 'test-series', operationType: 'formula', params: { format: { @@ -322,6 +322,8 @@ export const sampleAttributeWithReferenceLines = { isVisible: true, position: 'right', showSingleSeries: true, + legendSize: 'large', + shouldTruncate: false, }, preferredSeriesType: 'line', tickLabelsVisibilitySettings: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx index 68a628e23292..a8d0338e9eb7 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx @@ -54,12 +54,14 @@ export function SeriesName({ series, seriesId }: Props) { const onOutsideClick = (event: Event) => { if (event.target !== buttonRef.current) { setIsEditingEnabled(false); + onSave(); } }; const onKeyDown: KeyboardEventHandler = (event) => { if (event.key === 'Enter') { setIsEditingEnabled(false); + onSave(); } }; diff --git a/x-pack/test/observability_functional/apps/observability/exploratory_view.ts b/x-pack/test/observability_functional/apps/observability/exploratory_view.ts index b3adaa556dac..9aa33a8e9f65 100644 --- a/x-pack/test/observability_functional/apps/observability/exploratory_view.ts +++ b/x-pack/test/observability_functional/apps/observability/exploratory_view.ts @@ -85,8 +85,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); - expect(await find.existsByCssSelector('[title="Chrome Mobile iOS"]')).to.eql(true); - expect(await find.existsByCssSelector('[title="Mobile Safari"]')).to.eql(true); + expect( + await find.existsByCssSelector( + '[aria-label="Chrome Mobile iOS; Activate to hide series in graph"]' + ) + ).to.eql(true); + expect( + await find.existsByCssSelector( + '[aria-label="Mobile Safari; Activate to hide series in graph"]' + ) + ).to.eql(true); }); }); } From db82a4c8cc56e4f61dcdf0180d80b3a43fa18ba2 Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Thu, 20 Oct 2022 10:23:01 +0100 Subject: [PATCH 26/43] [APM] Hide information that is not relevant when viewing a service instrumented with a mobile agent (#143697) --- .../components/app/service_overview/index.tsx | 176 +++-------------- .../service_overview_charts.tsx | 180 ++++++++++++++++++ .../service_oveview_mobile_charts.tsx | 126 ++++++++++++ .../app/transaction_details/index.tsx | 38 ++-- .../app/transaction_overview/index.tsx | 43 +++-- .../templates/apm_service_template/index.tsx | 4 +- .../mobile_transaction_charts.tsx | 55 ++++++ 7 files changed, 444 insertions(+), 178 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/service_overview_charts.tsx create mode 100644 x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/service_oveview_mobile_charts.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/charts/transaction_charts/mobile_transaction_charts.tsx diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 94c0e90c1b44..bfd5b0c21ee8 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -5,31 +5,17 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React from 'react'; -import { - isRumAgentName, - isMobileAgentName, - isServerlessAgent, -} from '../../../../common/agent_name'; +import { EuiFlexGroupProps } from '@elastic/eui'; +import { isMobileAgentName } from '../../../../common/agent_name'; import { AnnotationsContextProvider } from '../../../context/annotations/annotations_context'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useBreakpoints } from '../../../hooks/use_breakpoints'; -import { LatencyChart } from '../../shared/charts/latency_chart'; -import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; -import { TransactionColdstartRateChart } from '../../shared/charts/transaction_coldstart_rate_chart'; -import { FailedTransactionRateChart } from '../../shared/charts/failed_transaction_rate_chart'; -import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; -import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; -import { ServiceOverviewInstancesChartAndTable } from './service_overview_instances_chart_and_table'; -import { ServiceOverviewThroughputChart } from './service_overview_throughput_chart'; -import { TransactionsTable } from '../../shared/transactions_table'; import { useApmParams } from '../../../hooks/use_apm_params'; -import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge'; -import { useApmRouter } from '../../../hooks/use_apm_router'; import { useTimeRange } from '../../../hooks/use_time_range'; +import { ServiceOverviewCharts } from './service_overview_charts/service_overview_charts'; +import { ServiceOverviewMobileCharts } from './service_overview_charts/service_oveview_mobile_charts'; /** * The height a chart should be if it's next to a table with 5 rows and a title. @@ -38,36 +24,35 @@ import { useTimeRange } from '../../../hooks/use_time_range'; export const chartHeight = 288; export function ServiceOverview() { - const { agentName, serviceName, fallbackToTransactions, runtimeName } = - useApmServiceContext(); + const { agentName, serviceName } = useApmServiceContext(); const { - query, - query: { environment, kuery, rangeFrom, rangeTo }, + query: { environment, rangeFrom, rangeTo }, } = useApmParams('/services/{serviceName}/overview'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const latencyChartHeight = 200; - // The default EuiFlexGroup breaks at 768, but we want to break at 1200, so we // observe the window width and set the flex directions of rows accordingly const { isLarge } = useBreakpoints(); const isSingleColumn = isLarge; + + const latencyChartHeight = 200; const nonLatencyChartHeight = isSingleColumn ? latencyChartHeight : chartHeight; - const rowDirection = isSingleColumn ? 'column' : 'row'; - const isRumAgent = isRumAgentName(agentName); + const rowDirection: EuiFlexGroupProps['direction'] = isSingleColumn + ? 'column' + : 'row'; + const isMobileAgent = isMobileAgentName(agentName); - const isServerless = isServerlessAgent(runtimeName); - const router = useApmRouter(); - const dependenciesLink = router.link('/services/{serviceName}/dependencies', { - path: { - serviceName, - }, - query, - }); + + const serviceOverviewProps = { + latencyChartHeight, + rowDirection, + nonLatencyChartHeight, + isSingleColumn, + }; return ( - - {fallbackToTransactions && ( - - - - )} - - - - - - - - - - - - - - - - - - - - {!isRumAgent && ( - - - - )} - - - - - - - - - - {isServerless ? ( - - - - ) : ( - - - - )} - {!isRumAgent && ( - - - - {i18n.translate( - 'xpack.apm.serviceOverview.dependenciesTableTabLink', - { defaultMessage: 'View dependencies' } - )} - - } - /> - - - )} - - - {!isRumAgent && !isMobileAgent && !isServerless && ( - - - - - - )} - + {isMobileAgent ? ( + + ) : ( + + )} ); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/service_overview_charts.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/service_overview_charts.tsx new file mode 100644 index 000000000000..802fc161cefd --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/service_overview_charts.tsx @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useApmRouter } from '../../../../hooks/use_apm_router'; +import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; +import { LatencyChart } from '../../../shared/charts/latency_chart'; +import { TransactionBreakdownChart } from '../../../shared/charts/transaction_breakdown_chart'; +import { TransactionColdstartRateChart } from '../../../shared/charts/transaction_coldstart_rate_chart'; +import { FailedTransactionRateChart } from '../../../shared/charts/failed_transaction_rate_chart'; +import { ServiceOverviewDependenciesTable } from '../service_overview_dependencies_table'; +import { ServiceOverviewErrorsTable } from '../service_overview_errors_table'; +import { ServiceOverviewInstancesChartAndTable } from '../service_overview_instances_chart_and_table'; +import { ServiceOverviewThroughputChart } from '../service_overview_throughput_chart'; +import { TransactionsTable } from '../../../shared/transactions_table'; +import { AggregatedTransactionsBadge } from '../../../shared/aggregated_transactions_badge'; +import { + isRumAgentName, + isServerlessAgent, +} from '../../../../../common/agent_name'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useTimeRange } from '../../../../hooks/use_time_range'; + +interface Props { + latencyChartHeight: number; + rowDirection: 'column' | 'row'; + nonLatencyChartHeight: number; + isSingleColumn: boolean; +} + +export function ServiceOverviewCharts({ + latencyChartHeight, + rowDirection, + nonLatencyChartHeight, + isSingleColumn, +}: Props) { + const router = useApmRouter(); + const { agentName, serviceName, fallbackToTransactions, runtimeName } = + useApmServiceContext(); + + const { + query, + query: { environment, kuery, rangeFrom, rangeTo }, + } = useApmParams('/services/{serviceName}/overview'); + + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const isRumAgent = isRumAgentName(agentName); + const isServerless = isServerlessAgent(runtimeName); + + const dependenciesLink = router.link('/services/{serviceName}/dependencies', { + path: { + serviceName, + }, + query, + }); + + return ( + + {fallbackToTransactions && ( + + + + )} + + + + + + + + + + + + + + + + + + + + {!isRumAgent && ( + + + + )} + + + + + + + + + + {isServerless ? ( + + + + ) : ( + + + + )} + {!isRumAgent && ( + + + + {i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableTabLink', + { defaultMessage: 'View dependencies' } + )} + + } + /> + + + )} + + + {!isRumAgent && !isServerless && ( + + + + + + )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/service_oveview_mobile_charts.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/service_oveview_mobile_charts.tsx new file mode 100644 index 000000000000..4dd20ca8a0d9 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/service_oveview_mobile_charts.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useApmRouter } from '../../../../hooks/use_apm_router'; +import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; +import { LatencyChart } from '../../../shared/charts/latency_chart'; +import { FailedTransactionRateChart } from '../../../shared/charts/failed_transaction_rate_chart'; +import { ServiceOverviewDependenciesTable } from '../service_overview_dependencies_table'; +import { ServiceOverviewThroughputChart } from '../service_overview_throughput_chart'; +import { TransactionsTable } from '../../../shared/transactions_table'; +import { AggregatedTransactionsBadge } from '../../../shared/aggregated_transactions_badge'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useTimeRange } from '../../../../hooks/use_time_range'; + +interface Props { + latencyChartHeight: number; + rowDirection: 'column' | 'row'; + nonLatencyChartHeight: number; + isSingleColumn: boolean; +} + +export function ServiceOverviewMobileCharts({ + latencyChartHeight, + rowDirection, + nonLatencyChartHeight, + isSingleColumn, +}: Props) { + const { fallbackToTransactions, serviceName } = useApmServiceContext(); + const router = useApmRouter(); + + const { + query, + query: { environment, kuery, rangeFrom, rangeTo }, + } = useApmParams('/services/{serviceName}/overview'); + + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const dependenciesLink = router.link('/services/{serviceName}/dependencies', { + path: { + serviceName, + }, + query, + }); + + return ( + + {fallbackToTransactions && ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableTabLink', + { defaultMessage: 'View dependencies' } + )} + + } + /> + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx index 255cacd0ce2c..41d6b2c7bf96 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx @@ -18,7 +18,11 @@ import { AggregatedTransactionsBadge } from '../../shared/aggregated_transaction import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { replace } from '../../shared/links/url_helpers'; import { TransactionDetailsTabs } from './transaction_details_tabs'; -import { isServerlessAgent } from '../../../../common/agent_name'; +import { + isMobileAgentName, + isServerlessAgent, +} from '../../../../common/agent_name'; +import { MobileTransactionCharts } from '../../shared/charts/transaction_charts/mobile_transaction_charts'; export function TransactionDetails() { const { path, query } = useApmParams( @@ -34,7 +38,7 @@ export function TransactionDetails() { } = query; const { start, end } = useTimeRange({ rangeFrom, rangeTo }); const apmRouter = useApmRouter(); - const { transactionType, fallbackToTransactions, runtimeName } = + const { transactionType, fallbackToTransactions, runtimeName, agentName } = useApmServiceContext(); const history = useHistory(); @@ -56,6 +60,7 @@ export function TransactionDetails() { ); const isServerless = isServerlessAgent(runtimeName); + const isMobileAgent = isMobileAgentName(agentName); return ( <> @@ -69,16 +74,25 @@ export function TransactionDetails() { - + {isMobileAgent ? ( + + ) : ( + + )} diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index adb89867d743..d0355b201e85 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -15,7 +15,11 @@ import { AggregatedTransactionsBadge } from '../../shared/aggregated_transaction import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { replace } from '../../shared/links/url_helpers'; import { TransactionsTable } from '../../shared/transactions_table'; -import { isServerlessAgent } from '../../../../common/agent_name'; +import { + isMobileAgentName, + isServerlessAgent, +} from '../../../../common/agent_name'; +import { MobileTransactionCharts } from '../../shared/charts/transaction_charts/mobile_transaction_charts'; export function TransactionOverview() { const { @@ -32,8 +36,13 @@ export function TransactionOverview() { const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const { transactionType, serviceName, fallbackToTransactions, runtimeName } = - useApmServiceContext(); + const { + transactionType, + serviceName, + fallbackToTransactions, + runtimeName, + agentName, + } = useApmServiceContext(); const history = useHistory(); @@ -49,6 +58,7 @@ export function TransactionOverview() { } const isServerless = isServerlessAgent(runtimeName); + const isMobileAgent = isMobileAgentName(agentName); return ( <> @@ -62,15 +72,24 @@ export function TransactionOverview() { )} - + {isMobileAgent ? ( + + ) : ( + + )} + + + + + + + + + + + + + + + + + ); +} From 27f4f73623961bba7da517fec9557b649aabedf8 Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Thu, 20 Oct 2022 11:28:30 +0200 Subject: [PATCH 27/43] [Security Solution][Endpoint][Response Actions] Restrict actions log API requests via RBAC controls (#142825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * gate actions log API requests via RBAC controls fixes elastic/security-team/issues/4365 * update test descriptions review changes (@gergoabraham) * restrict access to actions history on UI fixes elastic/security-team/issues/4802 * Update use_with_show_endpoint_responder.tsx refs 0397ea8555d70d340ba01eccb005beec8f423e41 * Decouple actions log management from `superuser` refs elastic/kibana/pull/142825#issuecomment-1274683765 (@kevinlog) * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * exclude actions log link based on permissions review changes (@kevinlog) * update logic and management links tests * remove irrelevant comment review changes * fix merge omits refs dcfb6a4fa92013304c024dd6bd1ad4e330e5fb04 * fix logic ref elastic/kibana/issues/143343 ref elastic/kibana/pull/143362 ref elastic/kibana/pull/143366 * update logic ref elastic/kibana/pull/143362 ref elastic/kibana/pull/143366 * separate ActionLog and HIE logic in links * make sure that HIE API error does not affect ActionLog access * hide ActionLog from Endpoint action items if no privilege * use 'canReadEndpointList' for Endpoints * show 'No Access' instead of 'Not Found' on privileged routes * update integration tests to expect 'no permission' Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Gergő Ábrahám --- .../use_navigation_items.tsx | 1 + .../use_with_show_endpoint_responder.tsx | 4 +- .../public/management/links.test.ts | 182 +++++++++++------- .../public/management/links.ts | 44 ++--- .../components/endpoint_details_tabs.tsx | 2 +- .../view/details/endpoint_details.tsx | 87 +++++---- .../view/hooks/use_endpoint_action_items.tsx | 11 +- .../pages/endpoint_hosts/view/index.test.tsx | 96 +++++++-- .../pages/host_isolation_exceptions/index.tsx | 1 + .../host_isolation_exceptions/view/hooks.ts | 1 + .../public/management/pages/index.tsx | 81 +++++--- .../pages/integration_tests/index.test.tsx | 131 ++++++++++++- .../endpoint/routes/actions/list.test.ts | 36 +++- .../server/endpoint/routes/actions/list.ts | 2 +- 14 files changed, 481 insertions(+), 198 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx index a4364c856452..3c043950b758 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -133,6 +133,7 @@ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { { ...securityNavGroup[SecurityNavGroupKey.manage], items: [ + // TODO: also hide other management pages based on authz privileges navTabs[SecurityPageName.endpoints], ...(isPolicyListEnabled ? [navTabs[SecurityPageName.policies]] : []), navTabs[SecurityPageName.trustedApps], diff --git a/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_with_show_endpoint_responder.tsx b/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_with_show_endpoint_responder.tsx index 7931e8a9765b..3ad37c76c7ff 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_with_show_endpoint_responder.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_with_show_endpoint_responder.tsx @@ -58,7 +58,9 @@ export const useWithShowEndpointResponder = (): ShowEndpointResponseActionsConso }, PageTitleComponent: () => <>{RESPONDER_PAGE_TITLE}, PageBodyComponent: () => , - ActionComponents: [ActionLogButton], + ActionComponents: endpointPrivileges.canReadActionsLogManagement + ? [ActionLogButton] + : undefined, }) .show(); } diff --git a/x-pack/plugins/security_solution/public/management/links.test.ts b/x-pack/plugins/security_solution/public/management/links.test.ts index 3ddd52b918ef..8ac24fe4ed09 100644 --- a/x-pack/plugins/security_solution/public/management/links.test.ts +++ b/x-pack/plugins/security_solution/public/management/links.test.ts @@ -9,22 +9,18 @@ import type { HttpSetup } from '@kbn/core/public'; import { coreMock } from '@kbn/core/public/mocks'; import { SecurityPageName } from '../app/types'; -import { licenseService } from '../common/hooks/use_license'; + +import { calculateEndpointAuthz } from '../../common/endpoint/service/authz'; import type { StartPlugins } from '../types'; import { links, getManagementFilteredLinks } from './links'; import { allowedExperimentalValues } from '../../common/experimental_features'; import { ExperimentalFeaturesService } from '../common/experimental_features_service'; -jest.mock('../common/hooks/use_license', () => { - const licenseServiceInstance = { - isPlatinumPlus: jest.fn(), - isEnterprise: jest.fn(() => true), - }; +jest.mock('../../common/endpoint/service/authz', () => { + const originalModule = jest.requireActual('../../common/endpoint/service/authz'); return { - licenseService: licenseServiceInstance, - useLicense: () => { - return licenseServiceInstance; - }, + ...originalModule, + calculateEndpointAuthz: jest.fn(), }; }); @@ -60,9 +56,13 @@ describe('links', () => { } as unknown as StartPlugins); }); - it('it returns all links without filtering when having isolate permission', async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); - fakeHttpServices.get.mockResolvedValue({ total: 0 }); + it('should return all links without filtering when having isolate permission', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue({ + canIsolateHost: true, + canUnIsolateHost: true, + canReadActionsLogManagement: true, + }); + const filteredLinks = await getManagementFilteredLinks( coreMockStarted, getPlugins(['superuser']) @@ -70,77 +70,115 @@ describe('links', () => { expect(filteredLinks).toEqual(links); }); - it('it returns all but response actions history link when NO isolation permission but HAS at least one host isolation exceptions entry', async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - fakeHttpServices.get.mockResolvedValue({ total: 1 }); - const filteredLinks = await getManagementFilteredLinks( - coreMockStarted, - getPlugins(['superuser']) - ); - expect(filteredLinks).toEqual({ - ...links, - links: links.links?.filter((link) => link.id !== SecurityPageName.responseActionsHistory), + describe('Action Logs', () => { + it('should return all but response actions link when no actions log access', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue({ + canIsolateHost: true, + canUnIsolateHost: true, + canReadActionsLogManagement: false, + }); + fakeHttpServices.get.mockResolvedValue({ total: 0 }); + + const filteredLinks = await getManagementFilteredLinks( + coreMockStarted, + getPlugins(['superuser']) + ); + expect(filteredLinks).toEqual({ + ...links, + links: links.links?.filter((link) => link.id !== SecurityPageName.responseActionsHistory), + }); }); }); - it('it returns all but response actions history when NO access to either response actions history or HIE but have at least one HIE entry', async () => { - fakeHttpServices.get.mockResolvedValue({ total: 1 }); - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - const filteredLinks = await getManagementFilteredLinks( - coreMockStarted, - getPlugins(['superuser']) - ); + describe('Host Isolation Exception', () => { + it('should return all but HIE when NO isolation permission due to privilege', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue({ + canIsolateHost: false, + canUnIsolateHost: false, + canReadActionsLogManagement: true, + }); - expect(filteredLinks).toEqual({ - ...links, - links: links.links?.filter((link) => link.id !== SecurityPageName.responseActionsHistory), + const filteredLinks = await getManagementFilteredLinks( + coreMockStarted, + getPlugins(['superuser']) + ); + expect(filteredLinks).toEqual({ + ...links, + links: links.links?.filter((link) => link.id !== SecurityPageName.hostIsolationExceptions), + }); }); - }); - it('it returns all but response actions history when NO enterprise license and can not isolate but HAS an HIE entry', async () => { - (licenseService.isEnterprise as jest.Mock).mockReturnValue(false); - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - fakeHttpServices.get.mockResolvedValue({ total: 1 }); - const filteredLinks = await getManagementFilteredLinks( - coreMockStarted, - getPlugins(['superuser']) - ); + it('should return all but HIE when NO isolation permission due to license and NO host isolation exceptions entry', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue({ + canIsolateHost: false, + canUnIsolateHost: true, + canReadActionsLogManagement: true, + }); + fakeHttpServices.get.mockResolvedValue({ total: 0 }); - expect(filteredLinks).toEqual({ - ...links, - links: links.links?.filter((link) => link.id !== SecurityPageName.responseActionsHistory), + const filteredLinks = await getManagementFilteredLinks( + coreMockStarted, + getPlugins(['superuser']) + ); + expect(filteredLinks).toEqual({ + ...links, + links: links.links?.filter((link) => link.id !== SecurityPageName.hostIsolationExceptions), + }); }); - }); - it('it returns all but response actions history and HIE links when NO enterprise license and no HIE entry', async () => { - (licenseService.isEnterprise as jest.Mock).mockReturnValue(false); - fakeHttpServices.get.mockResolvedValue({ total: 0 }); - const filteredLinks = await getManagementFilteredLinks( - coreMockStarted, - getPlugins(['superuser']) - ); + it('should return all when NO isolation permission due to license but HAS at least one host isolation exceptions entry', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue({ + canIsolateHost: false, + canUnIsolateHost: true, + canReadActionsLogManagement: true, + }); + fakeHttpServices.get.mockResolvedValue({ total: 1 }); - expect(filteredLinks).toEqual({ - ...links, - links: links.links?.filter( - (link) => - link.id !== SecurityPageName.hostIsolationExceptions && - link.id !== SecurityPageName.responseActionsHistory - ), + const filteredLinks = await getManagementFilteredLinks( + coreMockStarted, + getPlugins(['superuser']) + ); + expect(filteredLinks).toEqual(links); }); - }); - it('it returns filtered links when not having NO isolation permission and NO host isolation exceptions entry', async () => { - fakeHttpServices.get.mockResolvedValue({ total: 0 }); - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([])); - expect(filteredLinks).toEqual({ - ...links, - links: links.links?.filter( - (link) => - link.id !== SecurityPageName.hostIsolationExceptions && - link.id !== SecurityPageName.responseActionsHistory - ), + it('should not affect showing Action Log if getting from HIE API throws error', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue({ + canIsolateHost: false, + canUnIsolateHost: true, + canReadActionsLogManagement: true, + }); + fakeHttpServices.get.mockRejectedValue(new Error()); + + const filteredLinks = await getManagementFilteredLinks( + coreMockStarted, + getPlugins(['superuser']) + ); + expect(filteredLinks).toEqual({ + ...links, + links: links.links?.filter((link) => link.id !== SecurityPageName.hostIsolationExceptions), + }); + }); + + it('should not affect hiding Action Log if getting from HIE API throws error', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue({ + canIsolateHost: false, + canUnIsolateHost: true, + canReadActionsLogManagement: false, + }); + fakeHttpServices.get.mockRejectedValue(new Error()); + + const filteredLinks = await getManagementFilteredLinks( + coreMockStarted, + getPlugins(['superuser']) + ); + expect(filteredLinks).toEqual({ + ...links, + links: links.links?.filter( + (link) => + link.id !== SecurityPageName.hostIsolationExceptions && + link.id !== SecurityPageName.responseActionsHistory + ), + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 75436383547a..0eaa0202bd0d 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -246,10 +246,11 @@ export const getManagementFilteredLinks = async ( const fleetAuthz = plugins.fleet?.authz; const isEndpointRbacEnabled = ExperimentalFeaturesService.get().endpointRbacEnabled; const endpointPermissions = calculatePermissionsFromCapabilities(core.application.capabilities); + const linksToExclude: SecurityPageName[] = []; try { const currentUserResponse = await plugins.security.authc.getCurrentUser(); - const { canAccessEndpointManagement, canIsolateHost, canReadActionsLogManagement } = fleetAuthz + const { canReadActionsLogManagement, canIsolateHost, canUnIsolateHost } = fleetAuthz ? calculateEndpointAuthz( licenseService, fleetAuthz, @@ -259,37 +260,28 @@ export const getManagementFilteredLinks = async ( ) : getEndpointAuthzInitialState(); - if (!canAccessEndpointManagement) { - return excludeLinks([ - SecurityPageName.hostIsolationExceptions, - SecurityPageName.responseActionsHistory, - ]); + if (!canReadActionsLogManagement) { + linksToExclude.push(SecurityPageName.responseActionsHistory); } - if (!canReadActionsLogManagement) { - // <= enterprise license - const hostExceptionCount = await getHostIsolationExceptionTotal(core.http); - if (!canIsolateHost && !hostExceptionCount) { - return excludeLinks([ - SecurityPageName.hostIsolationExceptions, - SecurityPageName.responseActionsHistory, - ]); + if (!canIsolateHost && canUnIsolateHost) { + let shouldSeeHIEToBeAbleToDeleteEntries: boolean; + try { + const hostExceptionCount = await getHostIsolationExceptionTotal(core.http); + shouldSeeHIEToBeAbleToDeleteEntries = hostExceptionCount !== 0; + } catch { + shouldSeeHIEToBeAbleToDeleteEntries = false; } - return excludeLinks([SecurityPageName.responseActionsHistory]); - } else if (!canIsolateHost) { - const hostExceptionCount = await getHostIsolationExceptionTotal(core.http); - if (!hostExceptionCount) { - // <= platinum so exclude also links that require enterprise - return excludeLinks([ - SecurityPageName.hostIsolationExceptions, - SecurityPageName.responseActionsHistory, - ]); + + if (!shouldSeeHIEToBeAbleToDeleteEntries) { + linksToExclude.push(SecurityPageName.hostIsolationExceptions); } - return excludeLinks([SecurityPageName.responseActionsHistory]); + } else if (!canIsolateHost) { + linksToExclude.push(SecurityPageName.hostIsolationExceptions); } } catch { - return excludeLinks([SecurityPageName.hostIsolationExceptions]); + linksToExclude.push(SecurityPageName.hostIsolationExceptions); } - return links; + return excludeLinks(linksToExclude); }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx index a293e278721e..a8c219444e1c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx @@ -18,7 +18,7 @@ export enum EndpointDetailsTabsTypes { activityLog = 'activity_log', } -interface EndpointDetailsTabs { +export interface EndpointDetailsTabs { id: string; name: string; content: JSX.Element; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index 3a15b6b3873a..1d7c18016fc0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -7,6 +7,7 @@ import { EuiFlyoutBody, EuiFlyoutFooter, EuiLoadingContent, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { memo, useCallback, useEffect, useMemo } from 'react'; +import { useUserPrivileges } from '../../../../../common/components/user_privileges'; import { ResponseActionsLog } from '../../../../components/endpoint_response_actions_list/response_actions_log'; import { PolicyResponseWrapper } from '../../../../components/policy_response'; import type { HostMetadata } from '../../../../../../common/endpoint/types'; @@ -26,6 +27,7 @@ import { ActionsMenu } from './components/actions_menu'; import { EndpointDetailsFlyoutTabs, EndpointDetailsTabsTypes, + type EndpointDetailsTabs, } from './components/endpoint_details_tabs'; import { EndpointIsolationFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; import { EndpointDetailsFlyoutHeader } from './components/flyout_header'; @@ -41,6 +43,7 @@ export const EndpointDetails = memo(() => { const policyInfo = useEndpointSelector(policyVersionInfo); const hostStatus = useEndpointSelector(hostStatusInfo); const show = useEndpointSelector(showView); + const { canReadActionsLogManagement } = useUserPrivileges().endpointPrivileges; const ContentLoadingMarkup = useMemo( () => ( @@ -54,38 +57,53 @@ export const EndpointDetails = memo(() => { ); const getTabs = useCallback( - (id: string) => [ - { - id: EndpointDetailsTabsTypes.overview, - name: i18.OVERVIEW, - route: getEndpointDetailsPath({ - ...queryParams, - name: 'endpointDetails', - selected_endpoint: id, - }), - content: - hostDetails === undefined ? ( - ContentLoadingMarkup - ) : ( - - ), - }, - { - id: EndpointDetailsTabsTypes.activityLog, - name: i18.ACTIVITY_LOG.tabTitle, - route: getEndpointDetailsPath({ - ...queryParams, - name: 'endpointActivityLog', - selected_endpoint: id, - }), - content: , - }, - ], - [ContentLoadingMarkup, hostDetails, policyInfo, hostStatus, queryParams] + (id: string): EndpointDetailsTabs[] => { + const tabs: EndpointDetailsTabs[] = [ + { + id: EndpointDetailsTabsTypes.overview, + name: i18.OVERVIEW, + route: getEndpointDetailsPath({ + ...queryParams, + name: 'endpointDetails', + selected_endpoint: id, + }), + content: + hostDetails === undefined ? ( + ContentLoadingMarkup + ) : ( + + ), + }, + ]; + + // show the response actions history tab + // only when the user has the required permission + if (canReadActionsLogManagement) { + tabs.push({ + id: EndpointDetailsTabsTypes.activityLog, + name: i18.ACTIVITY_LOG.tabTitle, + route: getEndpointDetailsPath({ + ...queryParams, + name: 'endpointActivityLog', + selected_endpoint: id, + }), + content: , + }); + } + return tabs; + }, + [ + canReadActionsLogManagement, + ContentLoadingMarkup, + hostDetails, + policyInfo, + hostStatus, + queryParams, + ] ); const showFlyoutFooter = @@ -103,6 +121,7 @@ export const EndpointDetails = memo(() => { }); } }, [hostDetailsError, show, toasts]); + return ( <> {(show === 'policy_response' || show === 'isolate' || show === 'unisolate') && ( @@ -121,7 +140,9 @@ export const EndpointDetails = memo(() => { {(show === 'details' || show === 'activity_log') && ( )} diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx index 22ee28098683..4f0f7437ddc4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx @@ -40,8 +40,12 @@ export const useEndpointActionItems = ( const isResponseActionsConsoleEnabled = useIsExperimentalFeatureEnabled( 'responseActionsConsoleEnabled' ); - const { canAccessResponseConsole, canIsolateHost, canUnIsolateHost } = - useUserPrivileges().endpointPrivileges; + const { + canAccessResponseConsole, + canIsolateHost, + canUnIsolateHost, + canReadActionsLogManagement, + } = useUserPrivileges().endpointPrivileges; return useMemo(() => { if (endpointMetadata) { @@ -137,7 +141,7 @@ export const useEndpointActionItems = ( }, ] : []), - ...(options?.isEndpointList + ...(options?.isEndpointList && canReadActionsLogManagement ? [ { 'data-test-subj': 'actionsLink', @@ -249,6 +253,7 @@ export const useEndpointActionItems = ( }, [ allCurrentUrlParams, canAccessResponseConsole, + canReadActionsLogManagement, endpointMetadata, fleetAgentPolicies, getAppUrl, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 2c898e825258..0c3d0242672e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -56,6 +56,7 @@ import { initialUserPrivilegesState as mockInitialUserPrivilegesState } from '.. import { getUserPrivilegesMockDefaultValue } from '../../../../common/components/user_privileges/__mocks__'; import { ENDPOINT_CAPABILITIES } from '../../../../../common/endpoint/service/response_actions/constants'; +const mockUserPrivileges = useUserPrivileges as jest.Mock; // not sure why this can't be imported from '../../../../common/mock/formatted_relative'; // but sure enough it needs to be inline in this one file jest.mock('@kbn/i18n-react', () => { @@ -664,6 +665,7 @@ describe('when on the endpoint list page', () => { beforeEach(async () => { mockEndpointListApi(); + mockUserPrivileges.mockReturnValue(getUserPrivilegesMockDefaultValue()); reactTestingLibrary.act(() => { history.push(`${MANAGEMENT_PATH}/endpoints?selected_endpoint=1`); @@ -678,6 +680,7 @@ describe('when on the endpoint list page', () => { afterEach(() => { jest.clearAllMocks(); + mockUserPrivileges.mockReset(); }); it('should show the flyout and footer', async () => { @@ -777,29 +780,81 @@ describe('when on the endpoint list page', () => { }); }); - afterEach(reactTestingLibrary.cleanup); + afterEach(() => { + reactTestingLibrary.cleanup(); + }); - it('should start with the activity log tab as unselected', async () => { - const renderResult = await renderAndWaitForData(); - const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details'); - const activityLogTab = renderResult.getByTestId('endpoint-details-flyout-tab-activity_log'); + describe('when `canReadActionsLogManagement` is TRUE', () => { + it('should start with the activity log tab as unselected', async () => { + const renderResult = await renderAndWaitForData(); + const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details'); + const activityLogTab = renderResult.getByTestId( + 'endpoint-details-flyout-tab-activity_log' + ); + + expect(detailsTab).toHaveAttribute('aria-selected', 'true'); + expect(activityLogTab).toHaveAttribute('aria-selected', 'false'); + expect(renderResult.getByTestId('endpointDetailsFlyoutBody')).not.toBeNull(); + expect(renderResult.queryByTestId('endpointActivityLogFlyoutBody')).toBeNull(); + }); - expect(detailsTab).toHaveAttribute('aria-selected', 'true'); - expect(activityLogTab).toHaveAttribute('aria-selected', 'false'); - expect(renderResult.getByTestId('endpointDetailsFlyoutBody')).not.toBeNull(); - expect(renderResult.queryByTestId('endpointActivityLogFlyoutBody')).toBeNull(); + it('should show the activity log content when selected', async () => { + const renderResult = await renderAndWaitForData(); + const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details'); + const activityLogTab = renderResult.getByTestId( + 'endpoint-details-flyout-tab-activity_log' + ); + + userEvent.click(activityLogTab); + expect(detailsTab).toHaveAttribute('aria-selected', 'false'); + expect(activityLogTab).toHaveAttribute('aria-selected', 'true'); + expect(renderResult.getByTestId('endpointActivityLogFlyoutBody')).not.toBeNull(); + expect(renderResult.queryByTestId('endpointDetailsFlyoutBody')).toBeNull(); + }); }); - it('should show the activity log content when selected', async () => { - const renderResult = await renderAndWaitForData(); - const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details'); - const activityLogTab = renderResult.getByTestId('endpoint-details-flyout-tab-activity_log'); + describe('when `canReadActionsLogManagement` is FALSE', () => { + it('should not show the response actions history tab', async () => { + mockUserPrivileges.mockReturnValue({ + ...mockInitialUserPrivilegesState(), + endpointPrivileges: { + ...mockInitialUserPrivilegesState().endpointPrivileges, + canReadActionsLogManagement: false, + }, + }); + const renderResult = await renderAndWaitForData(); + const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details'); + const activityLogTab = renderResult.queryByTestId( + 'endpoint-details-flyout-tab-activity_log' + ); + + expect(detailsTab).toHaveAttribute('aria-selected', 'true'); + expect(activityLogTab).toBeNull(); + expect(renderResult.findByTestId('endpointDetailsFlyoutBody')).not.toBeNull(); + }); + + it('should show the overview tab when force loading actions history tab via URL', async () => { + mockUserPrivileges.mockReturnValue({ + ...mockInitialUserPrivilegesState(), + endpointPrivileges: { + ...mockInitialUserPrivilegesState().endpointPrivileges, + canReadActionsLogManagement: false, + }, + }); + reactTestingLibrary.act(() => { + history.push(`${MANAGEMENT_PATH}/endpoints?selected_endpoint=1&show=activity_log`); + }); + + const renderResult = await renderAndWaitForData(); + const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details'); + const activityLogTab = renderResult.queryByTestId( + 'endpoint-details-flyout-tab-activity_log' + ); - userEvent.click(activityLogTab); - expect(detailsTab).toHaveAttribute('aria-selected', 'false'); - expect(activityLogTab).toHaveAttribute('aria-selected', 'true'); - expect(renderResult.getByTestId('endpointActivityLogFlyoutBody')).not.toBeNull(); - expect(renderResult.queryByTestId('endpointDetailsFlyoutBody')).toBeNull(); + expect(detailsTab).toHaveAttribute('aria-selected', 'true'); + expect(activityLogTab).toBeNull(); + expect(renderResult.findByTestId('endpointDetailsFlyoutBody')).not.toBeNull(); + }); }); }); @@ -1082,7 +1137,7 @@ describe('when on the endpoint list page', () => { beforeEach(async () => { mockEndpointListApi(); - (useUserPrivileges as jest.Mock).mockReturnValue(getUserPrivilegesMockDefaultValue()); + mockUserPrivileges.mockReturnValue(getUserPrivilegesMockDefaultValue()); reactTestingLibrary.act(() => { history.push(`${MANAGEMENT_PATH}/endpoints`); @@ -1101,6 +1156,7 @@ describe('when on the endpoint list page', () => { afterEach(() => { jest.clearAllMocks(); + mockUserPrivileges.mockReset(); }); it('shows the Responder option when all 3 processes capabilities are present in the endpoint', async () => { @@ -1134,7 +1190,7 @@ describe('when on the endpoint list page', () => { }); it('hides isolate host option if canIsolateHost is false', () => { - (useUserPrivileges as jest.Mock).mockReturnValue({ + mockUserPrivileges.mockReturnValue({ ...mockInitialUserPrivilegesState(), endpointPrivileges: { ...mockInitialUserPrivilegesState().endpointPrivileges, diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/index.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/index.tsx index 27f3740394b3..ac6a191eddd8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/index.tsx @@ -18,6 +18,7 @@ import { HostIsolationExceptionsList } from './view/host_isolation_exceptions_li * Provides the routing container for the hosts related views */ export const HostIsolationExceptionsContainer = memo(() => { + // TODO: Probably should not silently redirect here const canAccessHostIsolationExceptionsLink = useLinkExists( SecurityPageName.hostIsolationExceptions ); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts index 091559203f6d..5e4c26e09942 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts @@ -16,6 +16,7 @@ import { HostIsolationExceptionsApiClient } from '../host_isolation_exceptions_a */ export function useCanSeeHostIsolationExceptionsMenu(): boolean { const http = useHttp(); + // TODO: why doesn't this use useUserPrivileges? const privileges = useEndpointPrivileges(); const apiQuery = useSummaryArtifact( HostIsolationExceptionsApiClient.getInstance(http), diff --git a/x-pack/plugins/security_solution/public/management/pages/index.tsx b/x-pack/plugins/security_solution/public/management/pages/index.tsx index 590b3786ece1..ec3d365cdbce 100644 --- a/x-pack/plugins/security_solution/public/management/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { ComponentType } from 'react'; import React, { memo } from 'react'; import { Switch, Redirect } from 'react-router-dom'; import { Route } from '@kbn/kibana-react-plugin/public'; @@ -30,8 +31,8 @@ import { getEndpointListPath } from '../common/routing'; import { useUserPrivileges } from '../../common/components/user_privileges'; import { HostIsolationExceptionsContainer } from './host_isolation_exceptions'; import { BlocklistContainer } from './blocklist'; -import { NoPermissions } from '../components/no_permissons'; import { ResponseActionsContainer } from './response_actions'; +import { NoPermissions } from '../components/no_permissons'; const EndpointTelemetry = () => ( @@ -75,44 +76,74 @@ const ResponseActionsTelemetry = () => ( ); +interface PrivilegedRouteProps { + path: string; + component: ComponentType<{}>; + privilege: boolean; +} + +const PrivilegedRoute = ({ component, privilege, path }: PrivilegedRouteProps) => { + return ; +}; + export const ManagementContainer = memo(() => { - const { loading, canAccessEndpointManagement, canReadActionsLogManagement } = - useUserPrivileges().endpointPrivileges; + const { + loading, + canReadPolicyManagement, + canReadBlocklist, + canReadTrustedApplications, + canReadEventFilters, + canReadActionsLogManagement, + canReadEndpointList, + } = useUserPrivileges().endpointPrivileges; // Lets wait until we can verify permissions if (loading) { return ; } - if (!canAccessEndpointManagement) { - return ( - <> - - - - ); - } - return ( - - - - + + + + - - {canReadActionsLogManagement && ( - + + + + {canReadEndpointList && ( + + + )} - - - ); diff --git a/x-pack/plugins/security_solution/public/management/pages/integration_tests/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/integration_tests/index.test.tsx index 0fcc8aa1f0ca..55d6730b8dc8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/integration_tests/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/integration_tests/index.test.tsx @@ -12,33 +12,146 @@ import '../../../common/mock/match_media'; import type { AppContextTestRender } from '../../../common/mock/endpoint'; import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { useCanSeeHostIsolationExceptionsMenu } from '../host_isolation_exceptions/view/hooks'; import { endpointPageHttpMock } from '../endpoint_hosts/mocks'; jest.mock('../../../common/components/user_privileges'); +jest.mock('../host_isolation_exceptions/view/hooks'); + +const useUserPrivilegesMock = useUserPrivileges as jest.Mock; +const useCanSeeHostIsolationExceptionsMenuMock = useCanSeeHostIsolationExceptionsMenu as jest.Mock; describe('when in the Administration tab', () => { let render: () => ReturnType; + const mockedContext = createAppRootMockRenderer(); beforeEach(() => { - const mockedContext = createAppRootMockRenderer(); endpointPageHttpMock(mockedContext.coreStart.http); render = () => mockedContext.render(); mockedContext.history.push('/administration/endpoints'); }); - it('should display the No Permissions if no sufficient privileges', async () => { - (useUserPrivileges as jest.Mock).mockReturnValue({ - endpointPrivileges: { loading: false, canAccessEndpointManagement: false }, + afterEach(() => { + useUserPrivilegesMock.mockReset(); + useCanSeeHostIsolationExceptionsMenuMock.mockReset(); + }); + + describe('when the user has no permissions', () => { + it('should display `no permission` if no `canAccessEndpointManagement`', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canAccessEndpointManagement: false }, + }); + + expect(await render().findByTestId('noIngestPermissions')).toBeTruthy(); + }); + + it('should display `no permission` if no `canReadPolicyManagement`', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canReadPolicyManagement: false }, + }); + + mockedContext.history.push('/administration/policy'); + expect(await render().findByTestId('noIngestPermissions')).toBeTruthy(); + }); + + it('should display `no permission` if no `canReadTrustedApplications`', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canReadTrustedApplications: false }, + }); + + mockedContext.history.push('/administration/trusted_apps'); + expect(await render().findByTestId('noIngestPermissions')).toBeTruthy(); + }); + + it('should display `no permission` if no `canReadEventFilters`', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canReadEventFilters: false }, + }); + + mockedContext.history.push('/administration/event_filters'); + expect(await render().findByTestId('noIngestPermissions')).toBeTruthy(); + }); + + it('should display `no permission` if no `canReadHostIsolationExceptions`', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canReadHostIsolationExceptions: false }, + }); + + mockedContext.history.push('/administration/host_isolation_exceptions'); + expect(await render().findByTestId('noIngestPermissions')).toBeTruthy(); + }); + + it('should display `no permission` if no `canReadBlocklist`', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canReadBlocklist: false }, + }); + + mockedContext.history.push('/administration/blocklist'); + expect(await render().findByTestId('noIngestPermissions')).toBeTruthy(); }); - expect(await render().findByTestId('noIngestPermissions')).not.toBeNull(); + it('should display `no permission` if no `canReadActionsLogManagement`', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canReadActionsLogManagement: false }, + }); + + mockedContext.history.push('/administration/response_actions_history'); + expect(await render().findByTestId('noIngestPermissions')).toBeTruthy(); + }); }); - it('should display the Management view if user has privileges', async () => { - (useUserPrivileges as jest.Mock).mockReturnValue({ - endpointPrivileges: { loading: false, canAccessEndpointManagement: true }, + describe('when the user has permissions', () => { + it('should display the Management view if user has privileges', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canReadEndpointList: true }, + }); + + expect(await render().findByTestId('endpointPage')).toBeTruthy(); + }); + + it('should display policy list page when `canReadPolicyManagement` is TRUE', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canReadPolicyManagement: true }, + }); + + mockedContext.history.push('/administration/policy'); + expect(await render().findByTestId('policyListPage')).toBeTruthy(); + }); + + it('should display trusted apps list page when `canReadTrustedApplications` is TRUE', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canReadTrustedApplications: true }, + }); + + mockedContext.history.push('/administration/trusted_apps'); + expect(await render().findByTestId('trustedAppsListPage-container')).toBeTruthy(); }); - expect(await render().findByTestId('endpointPage')).not.toBeNull(); + it('should display event filters list page when `canReadEventFilters` is TRUE', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canReadEventFilters: true }, + }); + + mockedContext.history.push('/administration/event_filters'); + expect(await render().findByTestId('EventFiltersListPage-container')).toBeTruthy(); + }); + + it('should display blocklist list page when `canReadBlocklist` is TRUE', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canReadBlocklist: true }, + }); + + mockedContext.history.push('/administration/blocklist'); + expect(await render().findByTestId('blocklistPage-container')).toBeTruthy(); + }); + + it('should display response actions history page when `canReadActionsLogManagement` is TRUE', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { loading: false, canReadActionsLogManagement: true }, + }); + + mockedContext.history.push('/administration/response_actions_history'); + expect(await render().findByTestId('responseActionsPage')).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/list.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/list.test.ts index c2a460fa88b7..b8e7211874cd 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/list.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/list.test.ts @@ -9,6 +9,7 @@ import type { SecuritySolutionRequestHandlerContextMock } from '../../../lib/det import type { AwaitedProperties } from '@kbn/utility-types'; import type { EndpointActionListRequestQuery } from '../../../../common/endpoint/schema/actions'; import type { EndpointAuthz } from '../../../../common/endpoint/types/authz'; +import type { License } from '@kbn/licensing-plugin/common/license'; import { createMockEndpointAppContextServiceSetupContract, createMockEndpointAppContextServiceStartContract, @@ -31,13 +32,16 @@ import { Subject } from 'rxjs'; import type { ILicense } from '@kbn/licensing-plugin/common/types'; import { licenseMock } from '@kbn/licensing-plugin/common/licensing.mock'; import { registerActionListRoutes } from './list'; +import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz/mocks'; interface CallApiRouteInterface { query?: EndpointActionListRequestQuery; + license?: License; authz?: Partial; } const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode: 'platinum' } }); +const Gold = licenseMock.createLicense({ license: { type: 'gold', mode: 'gold' } }); describe('Action List Route', () => { const superUser = { @@ -82,7 +86,7 @@ describe('Action List Route', () => { callApiRoute = async ( routePrefix: string, - { query, authz = {} }: CallApiRouteInterface + { query, license, authz = {} }: CallApiRouteInterface ): Promise> => { (startContract.security.authc.getCurrentUser as jest.Mock).mockImplementationOnce( () => superUser @@ -90,12 +94,21 @@ describe('Action List Route', () => { const ctx = createRouteHandlerContext(mockScopedClient, mockSavedObjectClient); + const withLicense = license ? license : Platinum; + licenseEmitter.next(withLicense); + ctx.securitySolution.endpointAuthz = { - ...ctx.securitySolution.endpointAuthz, + ...getEndpointAuthzInitialStateMock({ + canReadActionsLogManagement: + // mimicking the behavior of the EndpointAuthz class + // just so we can test the license check here + // since getEndpointAuthzInitialStateMock sets all keys to true + ctx.securitySolution.endpointAuthz.canAccessEndpointManagement && + licenseService.isPlatinumPlus(), + }), ...authz, }; - licenseEmitter.next(Platinum); const mockRequest = httpServerMock.createKibanaRequest({ query }); const [, routeHandler]: [ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -117,14 +130,23 @@ describe('Action List Route', () => { }); describe('User auth level', () => { - it('allows user with canReadSecuritySolution access to allow requests to API', async () => { - await callApiRoute(ENDPOINTS_ACTION_LIST_ROUTE, {}); + it('allows user with `canReadActionsLogManagement` access for API requests', async () => { + await callApiRoute(ENDPOINTS_ACTION_LIST_ROUTE, { + authz: { canReadActionsLogManagement: true }, + }); expect(mockResponse.ok).toBeCalled(); }); - it('does not allow user without canReadSecuritySolution access to allow requests to API', async () => { + it('does not allow user without `canReadActionsLogManagement` access for API requests', async () => { + await callApiRoute(ENDPOINTS_ACTION_LIST_ROUTE, { + authz: { canReadActionsLogManagement: false }, + }); + expect(mockResponse.forbidden).toBeCalled(); + }); + + it('does not allow user access to API requests if license is below platinum', async () => { await callApiRoute(ENDPOINTS_ACTION_LIST_ROUTE, { - authz: { canReadSecuritySolution: false }, + license: Gold, }); expect(mockResponse.forbidden).toBeCalled(); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/list.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/list.ts index a8b3ff5d7074..6a932f9bb8af 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/list.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/list.ts @@ -33,7 +33,7 @@ export function registerActionListRoutes( options: { authRequired: true, tags: ['access:securitySolution'] }, }, withEndpointAuthz( - { all: ['canReadSecuritySolution'] }, + { all: ['canReadActionsLogManagement'] }, endpointContext.logFactory.get('endpointActionList'), actionListHandler(endpointContext) ) From 9c0dd185770c5ea7f1e35b465a9f14f9bf563401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Thu, 20 Oct 2022 11:36:04 +0200 Subject: [PATCH 28/43] [Guided onboarding] Landing page updates (#143194) * [Guided onboarding] Updated landing page * [Guided onboarding] Finished landing page changes * [Guided onboarding] Fixed card for completed guides * [Guided onboarding] Fixed types errors * [Guided onboarding] Fixed i18n issues * Update src/plugins/home/public/application/components/guided_onboarding/use_case_card.tsx Co-authored-by: Cindy Chang * Update src/plugins/home/public/application/components/guided_onboarding/use_case_card.tsx Co-authored-by: Cindy Chang * Update src/plugins/home/public/application/components/guided_onboarding/use_case_card.tsx Co-authored-by: Cindy Chang * [Guided onboarding] Added CR comments * [Guided onboarding] Added view guide button to the completed guide * [Guided onboarding] Fixed the typo in kibana services * [Guided onboarding] Started moving the components out of home plugin into the guided onboarding package * [Guided onboarding] Fix the imports in the plugin * [Guided onboarding] Fix the tests in the new package * [CI] Auto-commit changed files from 'node scripts/generate codeowners' * [Guided onboarding] Fix the package file and the yarn.lock file * [Guided onboarding] Fix the build * [Guided onboarding] More refactoring * [Guided onboarding] More refactoring * [Guided onboarding] More refactoring * [Guided onboarding] More refactoring of types * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * [Guided onboarding] More refactoring of types * [Guided onboarding] Fix the types issues * [Guided onboarding] Update the tests for the api * [Guided onboarding] Fixed the i18n errors * [Guided onboarding] Fixed the i18n errors * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * [Guided onboarding] Fixed the jest tests * [Guided onboarding] Home changes * Update packages/kbn-guided-onboarding/src/components/landing_page/observability_link_card.tsx Co-authored-by: Kelly Murphy * [Guided onboarding] Address copy feedback * [Guided onboarding] Address CR feedback Co-authored-by: Cindy Chang Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kelly Murphy --- .github/CODEOWNERS | 1 + .i18nrc.json | 1 + .../public/components/main.tsx | 9 +- .../guided_onboarding_example/public/types.ts | 2 - package.json | 2 + packages/BUILD.bazel | 2 + packages/kbn-guided-onboarding/BUILD.bazel | 152 ++++++++++++++++++ packages/kbn-guided-onboarding/README.md | 3 + packages/kbn-guided-onboarding/index.ts | 11 ++ packages/kbn-guided-onboarding/jest.config.js | 13 ++ packages/kbn-guided-onboarding/kibana.jsonc | 7 + packages/kbn-guided-onboarding/package.json | 8 + .../__snapshots__/guide_card.test.tsx.snap | 52 ++++++ .../guide_card_footer.test.tsx.snap | 107 ++++++++++++ .../observability_link_card.test.tsx.snap | 28 ++++ .../landing_page/guide_card.test.tsx | 40 +++++ .../components/landing_page/guide_card.tsx | 96 +++++++++++ .../landing_page/guide_card_footer.test.tsx | 71 ++++++++ .../landing_page/guide_card_footer.tsx | 114 +++++++++++++ .../src/components/landing_page/index.ts | 11 ++ .../observability_link_card.test.tsx | 26 +++ .../landing_page/observability_link_card.tsx | 84 ++++++++++ .../components/landing_page/use_case_card.tsx | 105 ++++++++++++ .../kbn-guided-onboarding/src}/types.ts | 19 +-- packages/kbn-guided-onboarding/tsconfig.json | 18 +++ .../public/components/guide_button.tsx | 3 +- .../public/components/guide_panel.test.tsx | 2 +- .../public/components/guide_panel.tsx | 2 +- .../components/guide_panel_step.styles.ts | 2 +- .../public/components/guide_panel_step.tsx | 2 +- .../public/components/quit_guide_modal.tsx | 2 +- src/plugins/guided_onboarding/public/index.ts | 8 +- .../public/services/api.mocks.ts | 2 +- .../public/services/api.test.ts | 6 +- .../guided_onboarding/public/services/api.ts | 4 +- .../public/services/helpers.ts | 3 +- src/plugins/guided_onboarding/public/types.ts | 7 +- .../guided_onboarding/server/routes/index.ts | 2 +- src/plugins/guided_onboarding/tsconfig.json | 3 - src/plugins/home/kibana.json | 2 +- .../getting_started.test.tsx.snap | 36 +++-- .../__snapshots__/use_case_card.test.tsx.snap | 109 ------------- .../getting_started.test.tsx | 3 + .../guided_onboarding/getting_started.tsx | 70 +++++--- .../guided_onboarding/use_case_card.test.tsx | 42 ----- .../guided_onboarding/use_case_card.tsx | 149 ----------------- .../public/application/kibana_services.ts | 2 + src/plugins/home/public/plugin.ts | 5 +- src/plugins/home/tsconfig.json | 3 +- .../translations/translations/fr-FR.json | 9 -- .../translations/translations/ja-JP.json | 9 -- .../translations/translations/zh-CN.json | 9 -- yarn.lock | 8 + 53 files changed, 1084 insertions(+), 402 deletions(-) create mode 100644 packages/kbn-guided-onboarding/BUILD.bazel create mode 100644 packages/kbn-guided-onboarding/README.md create mode 100644 packages/kbn-guided-onboarding/index.ts create mode 100644 packages/kbn-guided-onboarding/jest.config.js create mode 100644 packages/kbn-guided-onboarding/kibana.jsonc create mode 100644 packages/kbn-guided-onboarding/package.json create mode 100644 packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card.test.tsx.snap create mode 100644 packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card_footer.test.tsx.snap create mode 100644 packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/observability_link_card.test.tsx.snap create mode 100644 packages/kbn-guided-onboarding/src/components/landing_page/guide_card.test.tsx create mode 100644 packages/kbn-guided-onboarding/src/components/landing_page/guide_card.tsx create mode 100644 packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.test.tsx create mode 100644 packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.tsx create mode 100644 packages/kbn-guided-onboarding/src/components/landing_page/index.ts create mode 100644 packages/kbn-guided-onboarding/src/components/landing_page/observability_link_card.test.tsx create mode 100644 packages/kbn-guided-onboarding/src/components/landing_page/observability_link_card.tsx create mode 100644 packages/kbn-guided-onboarding/src/components/landing_page/use_case_card.tsx rename {src/plugins/guided_onboarding/common => packages/kbn-guided-onboarding/src}/types.ts (88%) create mode 100644 packages/kbn-guided-onboarding/tsconfig.json delete mode 100644 src/plugins/home/public/application/components/guided_onboarding/__snapshots__/use_case_card.test.tsx.snap delete mode 100644 src/plugins/home/public/application/components/guided_onboarding/use_case_card.test.tsx delete mode 100644 src/plugins/home/public/application/components/guided_onboarding/use_case_card.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 83d63a954873..e7b1ab33b9be 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -905,6 +905,7 @@ packages/kbn-ftr-common-functional-services @elastic/kibana-operations packages/kbn-ftr-screenshot-filename @elastic/kibana-operations packages/kbn-generate @elastic/kibana-operations packages/kbn-get-repo-files @elastic/kibana-operations +packages/kbn-guided-onboarding @elastic/platform-onboarding packages/kbn-handlebars @elastic/kibana-security packages/kbn-hapi-mocks @elastic/kibana-core packages/kbn-i18n @elastic/kibana-core diff --git a/.i18nrc.json b/.i18nrc.json index 0b38c816d6c6..874c4ecbcc1b 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -43,6 +43,7 @@ "fieldFormats": "src/plugins/field_formats", "flot": "packages/kbn-flot-charts/lib", "guidedOnboarding": "src/plugins/guided_onboarding", + "guidedOnboardingPackage": "packages/kbn-guided-onboarding", "home": "src/plugins/home", "homePackages": "packages/home", "indexPatternEditor": "src/plugins/data_view_editor", diff --git a/examples/guided_onboarding_example/public/components/main.tsx b/examples/guided_onboarding_example/public/components/main.tsx index ee51bb2d8391..a65fd2324d34 100644 --- a/examples/guided_onboarding_example/public/components/main.tsx +++ b/examples/guided_onboarding_example/public/components/main.tsx @@ -25,13 +25,8 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import type { - GuidedOnboardingPluginStart, - GuideState, - GuideStepIds, - GuideId, - GuideStep, -} from '@kbn/guided-onboarding-plugin/public'; +import type { GuideState, GuideStepIds, GuideId, GuideStep } from '@kbn/guided-onboarding'; +import type { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public'; import { guidesConfig } from '@kbn/guided-onboarding-plugin/public'; interface MainProps { diff --git a/examples/guided_onboarding_example/public/types.ts b/examples/guided_onboarding_example/public/types.ts index 7d142de36e28..5f26a7ef532d 100755 --- a/examples/guided_onboarding_example/public/types.ts +++ b/examples/guided_onboarding_example/public/types.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public/types'; // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -16,6 +15,5 @@ export interface GuidedOnboardingExamplePluginSetup {} export interface GuidedOnboardingExamplePluginStart {} export interface AppPluginStartDependencies { - navigation: NavigationPublicPluginStart; guidedOnboarding: GuidedOnboardingPluginStart; } diff --git a/package.json b/package.json index 4681b51dc84d..452cc5c7dca6 100644 --- a/package.json +++ b/package.json @@ -323,6 +323,7 @@ "@kbn/es-types": "link:bazel-bin/packages/kbn-es-types", "@kbn/field-types": "link:bazel-bin/packages/kbn-field-types", "@kbn/flot-charts": "link:bazel-bin/packages/kbn-flot-charts", + "@kbn/guided-onboarding": "link:bazel-bin/packages/kbn-guided-onboarding", "@kbn/handlebars": "link:bazel-bin/packages/kbn-handlebars", "@kbn/hapi-mocks": "link:bazel-bin/packages/kbn-hapi-mocks", "@kbn/home-sample-data-card": "link:bazel-bin/packages/home/sample_data_card", @@ -1075,6 +1076,7 @@ "@types/kbn__ftr-screenshot-filename": "link:bazel-bin/packages/kbn-ftr-screenshot-filename/npm_module_types", "@types/kbn__generate": "link:bazel-bin/packages/kbn-generate/npm_module_types", "@types/kbn__get-repo-files": "link:bazel-bin/packages/kbn-get-repo-files/npm_module_types", + "@types/kbn__guided-onboarding": "link:bazel-bin/packages/kbn-guided-onboarding/npm_module_types", "@types/kbn__handlebars": "link:bazel-bin/packages/kbn-handlebars/npm_module_types", "@types/kbn__hapi-mocks": "link:bazel-bin/packages/kbn-hapi-mocks/npm_module_types", "@types/kbn__home-sample-data-card": "link:bazel-bin/packages/home/sample_data_card/npm_module_types", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 862f8a65f1a6..b68c27b27f3d 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -231,6 +231,7 @@ filegroup( "//packages/kbn-ftr-screenshot-filename:build", "//packages/kbn-generate:build", "//packages/kbn-get-repo-files:build", + "//packages/kbn-guided-onboarding:build", "//packages/kbn-handlebars:build", "//packages/kbn-hapi-mocks:build", "//packages/kbn-i18n:build", @@ -569,6 +570,7 @@ filegroup( "//packages/kbn-ftr-screenshot-filename:build_types", "//packages/kbn-generate:build_types", "//packages/kbn-get-repo-files:build_types", + "//packages/kbn-guided-onboarding:build_types", "//packages/kbn-handlebars:build_types", "//packages/kbn-hapi-mocks:build_types", "//packages/kbn-i18n:build_types", diff --git a/packages/kbn-guided-onboarding/BUILD.bazel b/packages/kbn-guided-onboarding/BUILD.bazel new file mode 100644 index 000000000000..b36e63daa822 --- /dev/null +++ b/packages/kbn-guided-onboarding/BUILD.bazel @@ -0,0 +1,152 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "kbn-guided-onboarding" +PKG_REQUIRE_NAME = "@kbn/guided-onboarding" + +SOURCE_FILES = glob( + [ + "**/*.ts", + "**/*.tsx", + ], + exclude = [ + "**/*.config.js", + "**/*.mock.*", + "**/*.test.*", + "**/*.stories.*", + "**/__snapshots__/**", + "**/integration_tests/**", + "**/mocks/**", + "**/scripts/**", + "**/storybook/**", + "**/test_fixtures/**", + "**/test_helpers/**", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "@npm//@elastic/eui", + "@npm//enzyme", + "@npm//react", + "//packages/kbn-i18n-react", + "//packages/kbn-i18n", + "//packages/core/http/core-http-browser", + "//packages/core/ui-settings/core-ui-settings-browser", +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "@npm//@types/node", + "@npm//@types/jest", + "@npm//@elastic/eui", + "@npm//@types/enzyme", + "@npm//@types/react", + "//packages/kbn-i18n-react:npm_module_types", + "//packages/kbn-i18n:npm_module_types", + "//packages/core/http/core-http-browser:npm_module_types", + "//packages/core/ui-settings/core-ui-settings-browser:npm_module_types", + "//packages/core/application/core-application-browser:npm_module_types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +jsts_transpiler( + name = "target_web", + srcs = SRCS, + build_pkg_name = package_name(), + web = True, + additional_args = [ + "--copy-files", + "--quiet" + ], +) + + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + declaration_map = True, + emit_declaration_only = True, + out_dir = "target_types", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":target_web"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-guided-onboarding/README.md b/packages/kbn-guided-onboarding/README.md new file mode 100644 index 000000000000..83863ad2f738 --- /dev/null +++ b/packages/kbn-guided-onboarding/README.md @@ -0,0 +1,3 @@ +# @kbn/guided-onboarding + +Empty package generated by @kbn/generate diff --git a/packages/kbn-guided-onboarding/index.ts b/packages/kbn-guided-onboarding/index.ts new file mode 100644 index 000000000000..2bb4e91906cf --- /dev/null +++ b/packages/kbn-guided-onboarding/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { GuideState, GuideId } from './src/types'; +export { GuideCard, ObservabilityLinkCard } from './src/components/landing_page'; +export type { UseCase } from './src/components/landing_page'; diff --git a/packages/kbn-guided-onboarding/jest.config.js b/packages/kbn-guided-onboarding/jest.config.js new file mode 100644 index 000000000000..cf180f15b8a9 --- /dev/null +++ b/packages/kbn-guided-onboarding/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-guided-onboarding'], +}; diff --git a/packages/kbn-guided-onboarding/kibana.jsonc b/packages/kbn-guided-onboarding/kibana.jsonc new file mode 100644 index 000000000000..4715fec8fbd5 --- /dev/null +++ b/packages/kbn-guided-onboarding/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/guided-onboarding", + "owner": "@elastic/platform-onboarding", + "runtimeDeps": [], + "typeDeps": [], +} diff --git a/packages/kbn-guided-onboarding/package.json b/packages/kbn-guided-onboarding/package.json new file mode 100644 index 000000000000..5838833e1427 --- /dev/null +++ b/packages/kbn-guided-onboarding/package.json @@ -0,0 +1,8 @@ +{ + "name": "@kbn/guided-onboarding", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "browser": "./target_web/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card.test.tsx.snap b/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card.test.tsx.snap new file mode 100644 index 000000000000..5633d3e4f781 --- /dev/null +++ b/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card.test.tsx.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`guide card snapshots should render use case card component for observability 1`] = ` + + } + isDarkTheme={false} + title="Observe my Kubernetes infrastructure" + useCase="observability" +/> +`; + +exports[`guide card snapshots should render use case card component for search 1`] = ` + + } + isDarkTheme={false} + title="Search my data" + useCase="search" +/> +`; + +exports[`guide card snapshots should render use case card component for security 1`] = ` + + } + isDarkTheme={false} + title="Protect my environment" + useCase="security" +/> +`; diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card_footer.test.tsx.snap b/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card_footer.test.tsx.snap new file mode 100644 index 000000000000..9b18465e91be --- /dev/null +++ b/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card_footer.test.tsx.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`guide card footer snapshots should render the footer when the guide has been completed 1`] = ` + + + +
+ + View guide + +
+
+`; + +exports[`guide card footer snapshots should render the footer when the guide has not started yet 1`] = ` +
+ + View guide + +
+`; + +exports[`guide card footer snapshots should render the footer when the guide is in progress 1`] = ` + + + +
+ + Continue + +
+
+`; + +exports[`guide card footer snapshots should render the footer when the guide is ready to complete 1`] = ` + + + +
+ + Continue + +
+
+`; + +exports[`guide card footer snapshots should render the footer when the guided onboarding has not started yet 1`] = ` +
+ + View guide + +
+`; diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/observability_link_card.test.tsx.snap b/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/observability_link_card.test.tsx.snap new file mode 100644 index 000000000000..e1dca8fdfb9e --- /dev/null +++ b/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/observability_link_card.test.tsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`observability link card snapshots should render link card for observability 1`] = ` + + + + View integrations + + +
+ } + isDarkTheme={false} + title="Observe my data" + useCase="observability" +/> +`; diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/guide_card.test.tsx b/packages/kbn-guided-onboarding/src/components/landing_page/guide_card.test.tsx new file mode 100644 index 000000000000..85e74e3bb23f --- /dev/null +++ b/packages/kbn-guided-onboarding/src/components/landing_page/guide_card.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { GuideCard, GuideCardProps } from './guide_card'; + +const defaultProps: GuideCardProps = { + useCase: 'search', + guides: [], + activateGuide: jest.fn(), + isDarkTheme: false, + addBasePath: jest.fn(), +}; + +describe('guide card', () => { + describe('snapshots', () => { + test('should render use case card component for search', async () => { + const component = await shallow(); + + expect(component).toMatchSnapshot(); + }); + test('should render use case card component for observability', async () => { + const component = await shallow(); + + expect(component).toMatchSnapshot(); + }); + test('should render use case card component for security', async () => { + const component = await shallow(); + + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/guide_card.tsx b/packages/kbn-guided-onboarding/src/components/landing_page/guide_card.tsx new file mode 100644 index 000000000000..df9a5abc9d6b --- /dev/null +++ b/packages/kbn-guided-onboarding/src/components/landing_page/guide_card.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +import { GuideState } from '../../types'; +import { GuideCardFooter } from './guide_card_footer'; +import { UseCase, UseCaseCard } from './use_case_card'; + +type GuideCardConstants = { + [key in UseCase]: { + i18nTexts: { + title: string; + description: string; + }; + }; +}; +const constants: GuideCardConstants = { + search: { + i18nTexts: { + title: i18n.translate('guidedOnboardingPackage.gettingStarted.guideCard.search.cardTitle', { + defaultMessage: 'Search my data', + }), + description: i18n.translate( + 'guidedOnboardingPackage.gettingStarted.guideCard.search.cardDescription', + { + defaultMessage: + 'Create a search experience for your websites, applications, workplace content, or anything in between.', + } + ), + }, + }, + observability: { + i18nTexts: { + title: i18n.translate( + 'guidedOnboardingPackage.gettingStarted.guideCard.observability.cardTitle', + { + defaultMessage: 'Observe my Kubernetes infrastructure', + } + ), + description: i18n.translate( + 'guidedOnboardingPackage.gettingStarted.guideCard.observability.cardDescription', + { + defaultMessage: + 'Monitor your Kubernetes infrastructure by consolidating your logs and metrics.', + } + ), + }, + }, + security: { + i18nTexts: { + title: i18n.translate('guidedOnboardingPackage.gettingStarted.guideCard.security.cardTitle', { + defaultMessage: 'Protect my environment', + }), + description: i18n.translate( + 'guidedOnboardingPackage.gettingStarted.guideCard.security.cardDescription', + { + defaultMessage: + 'Defend your environment against threats by unifying SIEM, endpoint security, and cloud security.', + } + ), + }, + }, +}; + +export interface GuideCardProps { + useCase: UseCase; + guides: GuideState[]; + activateGuide: (useCase: UseCase, guide?: GuideState) => Promise; + isDarkTheme: boolean; + addBasePath: (url: string) => string; +} +export const GuideCard = ({ + useCase, + guides, + activateGuide, + isDarkTheme, + addBasePath, +}: GuideCardProps) => { + return ( + } + isDarkTheme={isDarkTheme} + addBasePath={addBasePath} + /> + ); +}; diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.test.tsx b/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.test.tsx new file mode 100644 index 000000000000..d264afad6524 --- /dev/null +++ b/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { GuideCardFooter, GuideCardFooterProps } from './guide_card_footer'; +import { GuideState } from '../../types'; + +const defaultProps: GuideCardFooterProps = { + guides: [], + useCase: 'search', + activateGuide: jest.fn(), +}; + +const searchGuideState: GuideState = { + guideId: 'search', + status: 'not_started', + steps: [ + { id: 'add_data', status: 'complete' }, + { id: 'browse_docs', status: 'in_progress' }, + ], + isActive: true, +}; +describe('guide card footer', () => { + describe('snapshots', () => { + test('should render the footer when the guided onboarding has not started yet', async () => { + const component = await shallow(); + expect(component).toMatchSnapshot(); + }); + + test('should render the footer when the guide has not started yet', async () => { + const component = await shallow( + + ); + expect(component).toMatchSnapshot(); + }); + + test('should render the footer when the guide is in progress', async () => { + const component = await shallow( + + ); + expect(component).toMatchSnapshot(); + }); + + test('should render the footer when the guide is ready to complete', async () => { + const component = await shallow( + + ); + expect(component).toMatchSnapshot(); + }); + + test('should render the footer when the guide has been completed', async () => { + const component = await shallow( + + ); + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.tsx b/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.tsx new file mode 100644 index 000000000000..be7bc3265f35 --- /dev/null +++ b/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiButton, EuiProgress, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { GuideId, GuideState } from '../../types'; +import { UseCase } from './use_case_card'; + +const viewGuideLabel = i18n.translate( + 'guidedOnboardingPackage.gettingStarted.guideCard.startGuide.buttonLabel', + { + defaultMessage: 'View guide', + } +); + +const continueGuideLabel = i18n.translate( + 'guidedOnboardingPackage.gettingStarted.guideCard.continueGuide.buttonLabel', + { + defaultMessage: 'Continue', + } +); + +const completedLabel = i18n.translate( + 'guidedOnboardingPackage.gettingStarted.guideCard.progress.completedLabel', + { + defaultMessage: 'Completed', + } +); + +const inProgressLabel = i18n.translate( + 'guidedOnboardingPackage.gettingStarted.guideCard.progress.inProgressLabel', + { + defaultMessage: 'In progress', + } +); + +export interface GuideCardFooterProps { + guides: GuideState[]; + useCase: UseCase; + activateGuide: (useCase: UseCase, guideState?: GuideState) => void; +} +export const GuideCardFooter = ({ guides, useCase, activateGuide }: GuideCardFooterProps) => { + const guideState = guides.find((guide) => guide.guideId === (useCase as GuideId)); + const viewGuideButton = ( +
+ activateGuide(useCase, guideState)} + > + {viewGuideLabel} + +
+ ); + // guide has not started yet + if (!guideState || guideState.status === 'not_started') { + return viewGuideButton; + } + const { status, steps } = guideState; + const numberSteps = steps.length; + const numberCompleteSteps = steps.filter((step) => step.status === 'complete').length; + const stepsLabel = i18n.translate('guidedOnboardingPackage.gettingStarted.guideCard.stepsLabel', { + defaultMessage: '{progress} steps', + values: { + progress: `${numberCompleteSteps}/${numberSteps}`, + }, + }); + // guide is completed + if (status === 'complete') { + return ( + <> + + + {viewGuideButton} + + ); + } + // guide is in progress or ready to complete + return ( + <> + + +
+ activateGuide(useCase, guideState)} + > + {continueGuideLabel} + +
+ + ); +}; diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/index.ts b/packages/kbn-guided-onboarding/src/components/landing_page/index.ts new file mode 100644 index 000000000000..6d91a53775dd --- /dev/null +++ b/packages/kbn-guided-onboarding/src/components/landing_page/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { GuideCard } from './guide_card'; +export { ObservabilityLinkCard } from './observability_link_card'; +export type { UseCase } from './use_case_card'; diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/observability_link_card.test.tsx b/packages/kbn-guided-onboarding/src/components/landing_page/observability_link_card.test.tsx new file mode 100644 index 000000000000..191211a33953 --- /dev/null +++ b/packages/kbn-guided-onboarding/src/components/landing_page/observability_link_card.test.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { ObservabilityLinkCard } from './observability_link_card'; + +const defaultProps = { + navigateToApp: jest.fn(), + isDarkTheme: false, + addBasePath: jest.fn(), +}; + +describe('observability link card', () => { + describe('snapshots', () => { + test('should render link card for observability', async () => { + const component = await shallow(); + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/observability_link_card.tsx b/packages/kbn-guided-onboarding/src/components/landing_page/observability_link_card.tsx new file mode 100644 index 000000000000..995725900546 --- /dev/null +++ b/packages/kbn-guided-onboarding/src/components/landing_page/observability_link_card.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { NavigateToAppOptions } from '@kbn/core-application-browser'; +import { UseCaseCard } from './use_case_card'; + +interface LinkCardConstants { + observability: { + i18nTexts: { + title: string; + description: string; + }; + }; +} + +const constants: LinkCardConstants = { + observability: { + i18nTexts: { + title: i18n.translate( + 'guidedOnboardingPackage.gettingStarted.linkCard.observability.cardTitle', + { + defaultMessage: 'Observe my data', + } + ), + description: i18n.translate( + 'guidedOnboardingPackage.gettingStarted.linkCard.observability.cardDescription', + { + defaultMessage: + 'Add application, infrastructure, and user data through our pre-built integrations.', + } + ), + }, + }, +}; + +export const ObservabilityLinkCard = ({ + navigateToApp, + isDarkTheme, + addBasePath, +}: { + navigateToApp: (appId: string, options?: NavigateToAppOptions) => Promise; + isDarkTheme: boolean; + addBasePath: (url: string) => string; +}) => { + const navigateToIntegrations = () => { + navigateToApp('integrations', { + path: '/browse/infrastructure', + }); + }; + const button = ( + + + + {i18n.translate('guidedOnboardingPackage.gettingStarted.linkCard.buttonLabel', { + defaultMessage: 'View integrations', + })} + + + + ); + return ( + + ); +}; diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/use_case_card.tsx b/packages/kbn-guided-onboarding/src/components/landing_page/use_case_card.tsx new file mode 100644 index 000000000000..ef9373996297 --- /dev/null +++ b/packages/kbn-guided-onboarding/src/components/landing_page/use_case_card.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { ReactNode } from 'react'; +import { EuiCard, EuiText, EuiImage } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { GuideId } from '../../types'; + +type UseCaseConstants = { + [key in UseCase]: { + logAltText: string; + betaBadgeLabel: string; + }; +}; +const constants: UseCaseConstants = { + search: { + logAltText: i18n.translate('guidedOnboardingPackage.gettingStarted.search.iconName', { + defaultMessage: 'Enterprise Search logo', + }), + betaBadgeLabel: i18n.translate('guidedOnboardingPackage.gettingStarted.search.betaBadgeLabel', { + defaultMessage: 'search', + }), + }, + observability: { + logAltText: i18n.translate('guidedOnboardingPackage.gettingStarted.observability.iconName', { + defaultMessage: 'Observability logo', + }), + betaBadgeLabel: i18n.translate( + 'guidedOnboardingPackage.gettingStarted.observability.betaBadgeLabel', + { + defaultMessage: 'observe', + } + ), + }, + security: { + logAltText: i18n.translate('guidedOnboardingPackage.gettingStarted.security.iconName', { + defaultMessage: 'Security logo', + }), + betaBadgeLabel: i18n.translate( + 'guidedOnboardingPackage.gettingStarted.security.betaBadgeLabel', + { + defaultMessage: 'security', + } + ), + }, +}; + +export type UseCase = 'search' | 'observability' | 'security'; + +export interface UseCaseCardProps { + useCase: GuideId; + title: string; + description: string; + footer: ReactNode; + isDarkTheme: boolean; + addBasePath: (url: string) => string; +} + +export const UseCaseCard = ({ + useCase, + title, + description, + footer, + isDarkTheme, + addBasePath, +}: UseCaseCardProps) => { + const getImageUrl = (imageName: UseCase) => { + const imagePath = `/plugins/home/assets/solution_logos/${imageName}${ + isDarkTheme ? '_dark' : '' + }.png`; + + return addBasePath(imagePath); + }; + + const titleElement = ( + +

+ {title} +

+
+ ); + const descriptionElement = ( + +

{description}

+
+ ); + return ( + } + title={titleElement} + description={descriptionElement} + footer={footer} + betaBadgeProps={{ + label: constants[useCase].betaBadgeLabel, + }} + /> + ); +}; diff --git a/src/plugins/guided_onboarding/common/types.ts b/packages/kbn-guided-onboarding/src/types.ts similarity index 88% rename from src/plugins/guided_onboarding/common/types.ts rename to packages/kbn-guided-onboarding/src/types.ts index 435dd948d9f8..9a307464cefb 100644 --- a/src/plugins/guided_onboarding/common/types.ts +++ b/packages/kbn-guided-onboarding/src/types.ts @@ -14,13 +14,21 @@ export type SearchStepIds = 'add_data' | 'browse_docs' | 'search_experience'; export type GuideStepIds = ObservabilityStepIds | SecurityStepIds | SearchStepIds; +export interface GuideState { + guideId: GuideId; + status: GuideStatus; + isActive?: boolean; // Drives the current guide shown in the dropdown panel + steps: GuideStep[]; +} + /** * Allowed states for a guide: - * in_progress: Guide has been started + * not_started: Guide has not been started + * in_progress: At least one step in the guide has been started * ready_to_complete: All steps have been completed, but the "Continue using Elastic" button has not been clicked * complete: All steps and the guide have been completed */ -export type GuideStatus = 'in_progress' | 'ready_to_complete' | 'complete'; +export type GuideStatus = 'not_started' | 'in_progress' | 'ready_to_complete' | 'complete'; /** * Allowed states for each step in a guide: @@ -36,10 +44,3 @@ export interface GuideStep { id: GuideStepIds; status: StepStatus; } - -export interface GuideState { - guideId: GuideId; - status: GuideStatus; - isActive?: boolean; // Drives the current guide shown in the dropdown panel - steps: GuideStep[]; -} diff --git a/packages/kbn-guided-onboarding/tsconfig.json b/packages/kbn-guided-onboarding/tsconfig.json new file mode 100644 index 000000000000..a88e5af86e42 --- /dev/null +++ b/packages/kbn-guided-onboarding/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "stripInternal": false, + "types": [ + "jest", + "node", + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ] +} diff --git a/src/plugins/guided_onboarding/public/components/guide_button.tsx b/src/plugins/guided_onboarding/public/components/guide_button.tsx index b4b50d78942e..317f760f3a1e 100644 --- a/src/plugins/guided_onboarding/public/components/guide_button.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_button.tsx @@ -9,7 +9,8 @@ import React from 'react'; import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { GuideState } from '../../common/types'; +import type { GuideState } from '@kbn/guided-onboarding'; + import { getStepConfig } from '../services/helpers'; import { GuideButtonPopover } from './guide_button_popover'; diff --git a/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx b/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx index 4761446f7969..0ef85523d01f 100644 --- a/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx @@ -12,9 +12,9 @@ import React from 'react'; import { applicationServiceMock } from '@kbn/core-application-browser-mocks'; import { httpServiceMock } from '@kbn/core/public/mocks'; import { HttpSetup } from '@kbn/core/public'; +import type { GuideState } from '@kbn/guided-onboarding'; import { guidesConfig } from '../constants/guides_config'; -import type { GuideState } from '../../common/types'; import { apiService } from '../services/api'; import { GuidePanel } from './guide_panel'; import { registerTestBed, TestBed } from '@kbn/test-jest-helpers'; diff --git a/src/plugins/guided_onboarding/public/components/guide_panel.tsx b/src/plugins/guided_onboarding/public/components/guide_panel.tsx index 75f1f07cad5b..6bd33735ec94 100644 --- a/src/plugins/guided_onboarding/public/components/guide_panel.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_panel.tsx @@ -30,8 +30,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { ApplicationStart } from '@kbn/core/public'; +import type { GuideState, GuideStep as GuideStepStatus } from '@kbn/guided-onboarding'; -import type { GuideState, GuideStep as GuideStepStatus } from '../../common/types'; import type { GuideConfig, StepConfig } from '../types'; import type { ApiService } from '../services/api'; diff --git a/src/plugins/guided_onboarding/public/components/guide_panel_step.styles.ts b/src/plugins/guided_onboarding/public/components/guide_panel_step.styles.ts index 8d34d45b7a53..14b8f7826bb1 100644 --- a/src/plugins/guided_onboarding/public/components/guide_panel_step.styles.ts +++ b/src/plugins/guided_onboarding/public/components/guide_panel_step.styles.ts @@ -8,7 +8,7 @@ import { EuiThemeComputed } from '@elastic/eui'; import { css } from '@emotion/react'; -import { StepStatus } from '../../common/types'; +import type { StepStatus } from '@kbn/guided-onboarding'; export const getGuidePanelStepStyles = (euiTheme: EuiThemeComputed, stepStatus: StepStatus) => ({ stepNumber: css` diff --git a/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx b/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx index 107babb6ac0d..f79a26778c1a 100644 --- a/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx @@ -21,7 +21,7 @@ import { import { i18n } from '@kbn/i18n'; -import type { StepStatus } from '../../common/types'; +import type { StepStatus } from '@kbn/guided-onboarding'; import type { StepConfig } from '../types'; import { getGuidePanelStepStyles } from './guide_panel_step.styles'; diff --git a/src/plugins/guided_onboarding/public/components/quit_guide_modal.tsx b/src/plugins/guided_onboarding/public/components/quit_guide_modal.tsx index a7a7e34c311b..171750cd83db 100644 --- a/src/plugins/guided_onboarding/public/components/quit_guide_modal.tsx +++ b/src/plugins/guided_onboarding/public/components/quit_guide_modal.tsx @@ -9,7 +9,7 @@ import React, { useState } from 'react'; import { EuiText, EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { GuideState } from '../../common/types'; +import type { GuideState } from '@kbn/guided-onboarding'; import { apiService } from '../services/api'; interface QuitGuideModalProps { diff --git a/src/plugins/guided_onboarding/public/index.ts b/src/plugins/guided_onboarding/public/index.ts index 08ae777bb360..f4b2e6d8ff2f 100755 --- a/src/plugins/guided_onboarding/public/index.ts +++ b/src/plugins/guided_onboarding/public/index.ts @@ -12,8 +12,10 @@ import { GuidedOnboardingPlugin } from './plugin'; export function plugin(ctx: PluginInitializerContext) { return new GuidedOnboardingPlugin(ctx); } -export type { GuidedOnboardingPluginSetup, GuidedOnboardingPluginStart } from './types'; - -export type { GuideId, GuideStepIds, GuideState, GuideStep } from '../common/types'; +export type { + GuidedOnboardingPluginSetup, + GuidedOnboardingPluginStart, + GuidedOnboardingApi, +} from './types'; export { guidesConfig } from './constants/guides_config'; diff --git a/src/plugins/guided_onboarding/public/services/api.mocks.ts b/src/plugins/guided_onboarding/public/services/api.mocks.ts index ed2985a1dd74..21bb257cad68 100644 --- a/src/plugins/guided_onboarding/public/services/api.mocks.ts +++ b/src/plugins/guided_onboarding/public/services/api.mocks.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { GuideState } from '../../common/types'; +import type { GuideState } from '@kbn/guided-onboarding'; export const searchAddDataActiveState: GuideState = { guideId: 'search', diff --git a/src/plugins/guided_onboarding/public/services/api.test.ts b/src/plugins/guided_onboarding/public/services/api.test.ts index 8765af0fb07f..3503b68e5466 100644 --- a/src/plugins/guided_onboarding/public/services/api.test.ts +++ b/src/plugins/guided_onboarding/public/services/api.test.ts @@ -8,11 +8,11 @@ import { HttpSetup } from '@kbn/core/public'; import { httpServiceMock } from '@kbn/core/public/mocks'; +import type { GuideState } from '@kbn/guided-onboarding'; import { firstValueFrom, Subscription } from 'rxjs'; import { API_BASE_PATH } from '../../common/constants'; import { guidesConfig } from '../constants/guides_config'; -import type { GuideState } from '../../common/types'; import { ApiService } from './api'; import { noGuideActiveState, @@ -57,7 +57,7 @@ describe('GuidedOnboarding ApiService', () => { }); it('broadcasts the updated state', async () => { - await apiService.activateGuide(searchGuide); + await apiService.activateGuide(searchGuide, searchAddDataActiveState); const state = await firstValueFrom(apiService.fetchActiveGuideState$()); expect(state).toEqual(searchAddDataActiveState); @@ -151,7 +151,7 @@ describe('GuidedOnboarding ApiService', () => { expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { body: JSON.stringify({ isActive: true, - status: 'in_progress', + status: 'not_started', steps: [ { id: 'add_data', diff --git a/src/plugins/guided_onboarding/public/services/api.ts b/src/plugins/guided_onboarding/public/services/api.ts index 63fe8c3b7cb0..688e72fa8324 100644 --- a/src/plugins/guided_onboarding/public/services/api.ts +++ b/src/plugins/guided_onboarding/public/services/api.ts @@ -8,6 +8,7 @@ import { HttpSetup } from '@kbn/core/public'; import { BehaviorSubject, map, from, concatMap, of, Observable, firstValueFrom } from 'rxjs'; +import type { GuideState, GuideId, GuideStep, GuideStepIds } from '@kbn/guided-onboarding'; import { GuidedOnboardingApi } from '../types'; import { @@ -21,7 +22,6 @@ import { isStepReadyToComplete, } from './helpers'; import { API_BASE_PATH } from '../../common/constants'; -import type { GuideState, GuideId, GuideStep, GuideStepIds } from '../../common/types'; export class ApiService implements GuidedOnboardingApi { private client: HttpSetup | undefined; @@ -148,7 +148,7 @@ export class ApiService implements GuidedOnboardingApi { const updatedGuide: GuideState = { isActive: true, - status: 'in_progress', + status: 'not_started', steps: updatedSteps, guideId, }; diff --git a/src/plugins/guided_onboarding/public/services/helpers.ts b/src/plugins/guided_onboarding/public/services/helpers.ts index 30fbd5051215..c9bfc7dfb040 100644 --- a/src/plugins/guided_onboarding/public/services/helpers.ts +++ b/src/plugins/guided_onboarding/public/services/helpers.ts @@ -6,10 +6,9 @@ * Side Public License, v 1. */ -import type { GuideId, GuideState, GuideStepIds } from '../../common/types'; +import type { GuideId, GuideStepIds, GuideState, GuideStep } from '@kbn/guided-onboarding'; import { guidesConfig } from '../constants/guides_config'; import { GuideConfig, StepConfig } from '../types'; -import { GuideStep } from '../../common/types'; export const getGuideConfig = (guideId?: GuideId): GuideConfig | undefined => { if (guideId && Object.keys(guidesConfig).includes(guideId)) { diff --git a/src/plugins/guided_onboarding/public/types.ts b/src/plugins/guided_onboarding/public/types.ts index 7ca5d59ce5d7..41a26c6c32de 100755 --- a/src/plugins/guided_onboarding/public/types.ts +++ b/src/plugins/guided_onboarding/public/types.ts @@ -7,9 +7,8 @@ */ import { Observable } from 'rxjs'; -import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; import { HttpSetup } from '@kbn/core/public'; -import { GuideId, GuideState, GuideStepIds, StepStatus } from '../common/types'; +import type { GuideState, GuideId, GuideStepIds, StepStatus } from '@kbn/guided-onboarding'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface GuidedOnboardingPluginSetup {} @@ -18,10 +17,6 @@ export interface GuidedOnboardingPluginStart { guidedOnboardingApi?: GuidedOnboardingApi; } -export interface AppPluginStartDependencies { - navigation: NavigationPublicPluginStart; -} - export interface ClientConfigType { ui: boolean; } diff --git a/src/plugins/guided_onboarding/server/routes/index.ts b/src/plugins/guided_onboarding/server/routes/index.ts index adc65d0bf686..114571538948 100755 --- a/src/plugins/guided_onboarding/server/routes/index.ts +++ b/src/plugins/guided_onboarding/server/routes/index.ts @@ -8,8 +8,8 @@ import { schema } from '@kbn/config-schema'; import type { IRouter, SavedObjectsClient } from '@kbn/core/server'; +import type { GuideState } from '@kbn/guided-onboarding'; import { API_BASE_PATH } from '../../common/constants'; -import type { GuideState } from '../../common/types'; import { guidedSetupSavedObjectsType } from '../saved_objects'; const findGuideById = async (savedObjectsClient: SavedObjectsClient, guideId: string) => { diff --git a/src/plugins/guided_onboarding/tsconfig.json b/src/plugins/guided_onboarding/tsconfig.json index 153d0a2b85ee..4a024443419a 100644 --- a/src/plugins/guided_onboarding/tsconfig.json +++ b/src/plugins/guided_onboarding/tsconfig.json @@ -12,9 +12,6 @@ { "path": "../../core/tsconfig.json" }, - { - "path": "../navigation/tsconfig.json" - }, { "path": "../kibana_react/tsconfig.json" }, diff --git a/src/plugins/home/kibana.json b/src/plugins/home/kibana.json index 72b4d6cb8fd0..9a57af5b7051 100644 --- a/src/plugins/home/kibana.json +++ b/src/plugins/home/kibana.json @@ -8,6 +8,6 @@ "server": true, "ui": true, "requiredPlugins": ["dataViews", "share", "urlForwarding"], - "optionalPlugins": ["usageCollection", "customIntegrations", "cloud"], + "optionalPlugins": ["usageCollection", "customIntegrations", "cloud", "guidedOnboarding"], "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/home/public/application/components/guided_onboarding/__snapshots__/getting_started.test.tsx.snap b/src/plugins/home/public/application/components/guided_onboarding/__snapshots__/getting_started.test.tsx.snap index 89105328eec7..1cca5beaea25 100644 --- a/src/plugins/home/public/application/components/guided_onboarding/__snapshots__/getting_started.test.tsx.snap +++ b/src/plugins/home/public/application/components/guided_onboarding/__snapshots__/getting_started.test.tsx.snap @@ -35,12 +35,11 @@ exports[`getting started should render getting started component 1`] = ` size="s" />

- Select a starting point for a quick tour of how Elastic can help you do even more with your data. + Select a guide to help you make the most of your data.

- - - + + + @@ -79,7 +97,7 @@ exports[`getting started should render getting started component 1`] = ` data-test-subj="onboarding--skipUseCaseTourLink" onClick={[Function]} > - No thanks, I’ll explore on my own. + I’d like to do something else (skip)
diff --git a/src/plugins/home/public/application/components/guided_onboarding/__snapshots__/use_case_card.test.tsx.snap b/src/plugins/home/public/application/components/guided_onboarding/__snapshots__/use_case_card.test.tsx.snap deleted file mode 100644 index 7ace62718323..000000000000 --- a/src/plugins/home/public/application/components/guided_onboarding/__snapshots__/use_case_card.test.tsx.snap +++ /dev/null @@ -1,109 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`use case card should render use case card component for observability 1`] = ` - -

- Get end-to-end observability into your environments by consolidating your logs, metrics, and traces. -

- - } - display="subdued" - image={ - - } - onClick={[Function]} - textAlign="left" - title={ - -

- - Monitor my environments - -

-
- } -/> -`; - -exports[`use case card should render use case card component for search 1`] = ` - -

- Create a finely-tuned search experience for your websites, applications, workplace content, and more. -

- - } - display="subdued" - image={ - - } - onClick={[Function]} - textAlign="left" - title={ - -

- - Search my data - -

-
- } -/> -`; - -exports[`use case card should render use case card component for security 1`] = ` - -

- Protect your environment against threats by unifying SIEM, endpoint security, and cloud security in one place. -

- - } - display="subdued" - image={ - - } - onClick={[Function]} - textAlign="left" - title={ - -

- - Protect my environment - -

-
- } -/> -`; diff --git a/src/plugins/home/public/application/components/guided_onboarding/getting_started.test.tsx b/src/plugins/home/public/application/components/guided_onboarding/getting_started.test.tsx index 6e0e01b73a46..8b2ca1de830a 100644 --- a/src/plugins/home/public/application/components/guided_onboarding/getting_started.test.tsx +++ b/src/plugins/home/public/application/components/guided_onboarding/getting_started.test.tsx @@ -31,6 +31,9 @@ jest.mock('../../kibana_services', () => { prepend: jest.fn(), }, }, + guidedOnboardingService: { + fetchAllGuidesState: jest.fn(), + }, }), }; }); diff --git a/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx b/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx index f3131e63ad39..e63676ca3ca7 100644 --- a/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx +++ b/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { EuiFlexGrid, EuiFlexItem, @@ -24,28 +24,30 @@ import { css } from '@emotion/react'; import { METRIC_TYPE } from '@kbn/analytics'; import { i18n } from '@kbn/i18n'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import type { GuideState, GuideId, UseCase } from '@kbn/guided-onboarding'; +import { GuideCard, ObservabilityLinkCard } from '@kbn/guided-onboarding'; import { getServices } from '../../kibana_services'; import { KEY_ENABLE_WELCOME } from '../home'; -import { UseCaseCard } from './use_case_card'; const homeBreadcrumb = i18n.translate('home.breadcrumbs.homeTitle', { defaultMessage: 'Home' }); const gettingStartedBreadcrumb = i18n.translate('home.breadcrumbs.gettingStartedTitle', { - defaultMessage: 'Getting Started', + defaultMessage: 'Guided setup', }); const title = i18n.translate('home.guidedOnboarding.gettingStarted.useCaseSelectionTitle', { defaultMessage: 'What would you like to do first?', }); const subtitle = i18n.translate('home.guidedOnboarding.gettingStarted.useCaseSelectionSubtitle', { - defaultMessage: - 'Select a starting point for a quick tour of how Elastic can help you do even more with your data.', + defaultMessage: 'Select a guide to help you make the most of your data.', }); const skipText = i18n.translate('home.guidedOnboarding.gettingStarted.skip.buttonLabel', { - defaultMessage: `No thanks, I’ll explore on my own.`, + defaultMessage: `I’d like to do something else (skip)`, }); export const GettingStarted = () => { - const { application, trackUiMetric, chrome } = getServices(); + const { application, trackUiMetric, chrome, guidedOnboardingService, http, uiSettings } = + getServices(); + const [guidesState, setGuidesState] = useState([]); useEffect(() => { chrome.setBreadcrumbs([ @@ -63,6 +65,17 @@ export const GettingStarted = () => { ]); }, [chrome, trackUiMetric]); + const fetchGuidesState = useCallback(async () => { + const allGuides = await guidedOnboardingService?.fetchAllGuidesState(); + if (allGuides) { + setGuidesState(allGuides.state); + } + }, [guidedOnboardingService]); + + useEffect(() => { + fetchGuidesState(); + }, [fetchGuidesState]); + const onSkip = () => { trackUiMetric(METRIC_TYPE.CLICK, 'guided_onboarding__skipped'); // disable welcome screen on the home page @@ -73,6 +86,12 @@ export const GettingStarted = () => { const paddingCss = css` padding: calc(${euiTheme.size.base}*3) calc(${euiTheme.size.base}*4); `; + + const isDarkTheme = uiSettings.get('theme:darkMode'); + const activateGuide = async (useCase: UseCase, guideState?: GuideState) => { + await guidedOnboardingService?.activateGuide(useCase as GuideId, guideState); + // TODO error handling https://github.com/elastic/kibana/issues/139798 + }; return ( @@ -81,21 +100,36 @@ export const GettingStarted = () => {

{title}

- +

{subtitle}

- - - - - - - - - - + + {['search', 'observability', 'observabilityLink', 'security'].map((useCase) => { + if (useCase === 'observabilityLink') { + return ( + + + + ); + } + return ( + + + + ); + })} diff --git a/src/plugins/home/public/application/components/guided_onboarding/use_case_card.test.tsx b/src/plugins/home/public/application/components/guided_onboarding/use_case_card.test.tsx deleted file mode 100644 index b899d533572c..000000000000 --- a/src/plugins/home/public/application/components/guided_onboarding/use_case_card.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { shallow } from 'enzyme'; - -import { UseCaseCard } from './use_case_card'; - -jest.mock('../../kibana_services', () => { - const { applicationServiceMock, uiSettingsServiceMock, httpServiceMock } = - jest.requireActual('@kbn/core/public/mocks'); - return { - getServices: () => ({ - application: applicationServiceMock.createStartContract(), - trackUiMetric: jest.fn(), - uiSettings: uiSettingsServiceMock.createStartContract(), - http: httpServiceMock.createStartContract(), - }), - }; -}); -describe('use case card', () => { - test('should render use case card component for search', async () => { - const component = await shallow(); - - expect(component).toMatchSnapshot(); - }); - test('should render use case card component for observability', async () => { - const component = await shallow(); - - expect(component).toMatchSnapshot(); - }); - test('should render use case card component for security', async () => { - const component = await shallow(); - - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/plugins/home/public/application/components/guided_onboarding/use_case_card.tsx b/src/plugins/home/public/application/components/guided_onboarding/use_case_card.tsx deleted file mode 100644 index cb5d765fc66c..000000000000 --- a/src/plugins/home/public/application/components/guided_onboarding/use_case_card.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { EuiCard, EuiText, EuiTitle, EuiImage } from '@elastic/eui'; - -import { METRIC_TYPE } from '@kbn/analytics'; -import { i18n } from '@kbn/i18n'; - -import { getServices } from '../../kibana_services'; - -type UseCaseConstants = { - [key in UseCase]: { - i18nTexts: { - title: string; - description: string; - }; - logo: { - altText: string; - }; - navigateOptions: { - appId: string; - path?: string; - }; - }; -}; -const constants: UseCaseConstants = { - search: { - i18nTexts: { - title: i18n.translate('home.guidedOnboarding.gettingStarted.search.cardTitle', { - defaultMessage: 'Search my data', - }), - description: i18n.translate('home.guidedOnboarding.gettingStarted.search.cardDescription', { - defaultMessage: - 'Create a finely-tuned search experience for your websites, applications, workplace content, and more.', - }), - }, - logo: { - altText: i18n.translate('home.guidedOnboarding.gettingStarted.search.iconName', { - defaultMessage: 'Enterprise Search logo', - }), - }, - navigateOptions: { - appId: 'enterpriseSearch', - // when navigating to ent search, do not provide path - }, - }, - observability: { - i18nTexts: { - title: i18n.translate('home.guidedOnboarding.gettingStarted.observability.cardTitle', { - defaultMessage: 'Monitor my environments', - }), - description: i18n.translate( - 'home.guidedOnboarding.gettingStarted.observability.cardDescription', - { - defaultMessage: - 'Get end-to-end observability into your environments by consolidating your logs, metrics, and traces.', - } - ), - }, - logo: { - altText: i18n.translate('home.guidedOnboarding.gettingStarted.observability.iconName', { - defaultMessage: 'Observability logo', - }), - }, - navigateOptions: { - appId: 'observability', - path: '/overview', - }, - }, - security: { - i18nTexts: { - title: i18n.translate('home.guidedOnboarding.gettingStarted.security.cardTitle', { - defaultMessage: 'Protect my environment', - }), - description: i18n.translate('home.guidedOnboarding.gettingStarted.security.cardDescription', { - defaultMessage: - 'Protect your environment against threats by unifying SIEM, endpoint security, and cloud security in one place.', - }), - }, - logo: { - altText: i18n.translate('home.guidedOnboarding.gettingStarted.security.iconName', { - defaultMessage: 'Security logo', - }), - }, - navigateOptions: { - appId: 'securitySolutionUI', - path: '/overview', - }, - }, -}; - -export type UseCase = 'search' | 'observability' | 'security'; -export interface UseCaseProps { - useCase: UseCase; -} - -export const UseCaseCard = ({ useCase }: UseCaseProps) => { - const { application, trackUiMetric, uiSettings, http } = getServices(); - - const isDarkTheme = uiSettings.get('theme:darkMode'); - - const getImageUrl = (imageName: UseCase) => { - const imagePath = `/plugins/home/assets/solution_logos/${imageName}${ - isDarkTheme ? '_dark' : '' - }.png`; - - return http.basePath.prepend(imagePath); - }; - - const onUseCaseSelection = () => { - trackUiMetric(METRIC_TYPE.CLICK, `guided_onboarding__use_case__${useCase}`); - - localStorage.setItem(`guidedOnboarding.${useCase}.tourActive`, JSON.stringify(true)); - application.navigateToApp(constants[useCase].navigateOptions.appId, { - path: constants[useCase].navigateOptions.path, - }); - }; - - const title = ( - -

- {constants[useCase].i18nTexts.title} -

-
- ); - const description = ( - -

{constants[useCase].i18nTexts.description}

-
- ); - return ( - } - title={title} - description={description} - // Used for FS tracking - data-test-subj={`onboarding--${useCase}UseCaseCard`} - onClick={onUseCaseSelection} - /> - ); -}; diff --git a/src/plugins/home/public/application/kibana_services.ts b/src/plugins/home/public/application/kibana_services.ts index af4591c8e736..b622cf862f31 100644 --- a/src/plugins/home/public/application/kibana_services.ts +++ b/src/plugins/home/public/application/kibana_services.ts @@ -20,6 +20,7 @@ import { UiCounterMetricType } from '@kbn/analytics'; import { UrlForwardingStart } from '@kbn/url-forwarding-plugin/public'; import { DataViewsContract } from '@kbn/data-views-plugin/public'; import { SharePluginSetup } from '@kbn/share-plugin/public'; +import { GuidedOnboardingApi } from '@kbn/guided-onboarding-plugin/public'; import { TutorialService } from '../services/tutorials'; import { AddDataService } from '../services/add_data'; import { FeatureCatalogueRegistry } from '../services/feature_catalogue'; @@ -49,6 +50,7 @@ export interface HomeKibanaServices { tutorialService: TutorialService; addDataService: AddDataService; welcomeService: WelcomeService; + guidedOnboardingService?: GuidedOnboardingApi; } let services: HomeKibanaServices | null = null; diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index e27ddf107a5e..c85f920fca6e 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -18,6 +18,7 @@ import { i18n } from '@kbn/i18n'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import { UrlForwardingSetup, UrlForwardingStart } from '@kbn/url-forwarding-plugin/public'; +import type { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public'; import { AppNavLinkStatus } from '@kbn/core/public'; import { SharePluginSetup } from '@kbn/share-plugin/public'; import type { CloudSetup } from '@kbn/cloud-plugin/public'; @@ -40,6 +41,7 @@ import { export interface HomePluginStartDependencies { dataViews: DataViewsPublicPluginStart; urlForwarding: UrlForwardingStart; + guidedOnboarding: GuidedOnboardingPluginStart; } export interface HomePluginSetupDependencies { @@ -78,7 +80,7 @@ export class HomePublicPlugin const trackUiMetric = usageCollection ? usageCollection.reportUiCounter.bind(usageCollection, 'Kibana_home') : () => {}; - const [coreStart, { dataViews, urlForwarding: urlForwardingStart }] = + const [coreStart, { dataViews, urlForwarding: urlForwardingStart, guidedOnboarding }] = await core.getStartServices(); setServices({ share, @@ -102,6 +104,7 @@ export class HomePublicPlugin addDataService: this.addDataService, featureCatalogue: this.featuresCatalogueRegistry, welcomeService: this.welcomeService, + guidedOnboardingService: guidedOnboarding.guidedOnboardingApi, }); coreStart.chrome.docTitle.change( i18n.translate('home.pageTitle', { defaultMessage: 'Home' }) diff --git a/src/plugins/home/tsconfig.json b/src/plugins/home/tsconfig.json index af121720eee0..af12fb12f338 100644 --- a/src/plugins/home/tsconfig.json +++ b/src/plugins/home/tsconfig.json @@ -16,6 +16,7 @@ { "path": "../share/tsconfig.json" }, { "path": "../url_forwarding/tsconfig.json" }, { "path": "../usage_collection/tsconfig.json" }, - { "path": "../../../x-pack/plugins/cloud/tsconfig.json" } + { "path": "../guided_onboarding/tsconfig.json" }, + { "path": "../../../x-pack/plugins/cloud/tsconfig.json" }, ] } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 0da5af908ac5..28fa7768616e 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -3102,15 +3102,6 @@ "home.breadcrumbs.integrationsAppTitle": "Intégrations", "home.exploreButtonLabel": "Explorer par moi-même", "home.exploreYourDataDescription": "Une fois toutes les étapes terminées, vous êtes prêt à explorer vos données.", - "home.guidedOnboarding.gettingStarted.observability.cardDescription": "Obtenez une observabilité de bout en bout de vos environnements en consolidant vos journaux, vos indicateurs et vos traces.", - "home.guidedOnboarding.gettingStarted.observability.cardTitle": "Monitorer mes environnements", - "home.guidedOnboarding.gettingStarted.observability.iconName": "Logo Observability", - "home.guidedOnboarding.gettingStarted.search.cardDescription": "Créez une expérience de recherche fine pour vos sites web, vos applications, votre contenu workplace, etc.", - "home.guidedOnboarding.gettingStarted.search.cardTitle": "Rechercher dans mes données", - "home.guidedOnboarding.gettingStarted.search.iconName": "Logo Entreprise Search", - "home.guidedOnboarding.gettingStarted.security.cardDescription": "Protégez votre environnement contre les menaces en unifiant SIEM, la sécurité des points de terminaison et la sécurité cloud en un seul endroit.", - "home.guidedOnboarding.gettingStarted.security.cardTitle": "Protéger mon environnement", - "home.guidedOnboarding.gettingStarted.security.iconName": "Logo Security", "home.guidedOnboarding.gettingStarted.skip.buttonLabel": "Non merci, je vais explorer par moi-même.", "home.guidedOnboarding.gettingStarted.useCaseSelectionSubtitle": "Sélectionnez un point de départ pour une visite rapide de la façon dont Elastic peut vous aider à faire encore plus avec vos données.", "home.guidedOnboarding.gettingStarted.useCaseSelectionTitle": "Par quoi voulez-vous commencer ?", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 742cceebbd8c..4a7a973e26e3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3097,15 +3097,6 @@ "home.breadcrumbs.integrationsAppTitle": "統合", "home.exploreButtonLabel": "独りで閲覧", "home.exploreYourDataDescription": "すべてのステップを終えたら、データ閲覧準備の完了です。", - "home.guidedOnboarding.gettingStarted.observability.cardDescription": "ログ、メトリック、トレースを統合し、環境に対するエンドツーエンドのオブザーバビリティを実現します。", - "home.guidedOnboarding.gettingStarted.observability.cardTitle": "環境を監視", - "home.guidedOnboarding.gettingStarted.observability.iconName": "オブザーバビリティロゴ", - "home.guidedOnboarding.gettingStarted.search.cardDescription": "Webサイト、アプリケーション、workplaceコンテンツなどに合った、微調整された検索エクスペリエンスを作成します。", - "home.guidedOnboarding.gettingStarted.search.cardTitle": "データを検索", - "home.guidedOnboarding.gettingStarted.search.iconName": "エンタープライズ サーチロゴ", - "home.guidedOnboarding.gettingStarted.security.cardDescription": "SIEM、エンドポイントセキュリティ、クラウドセキュリティを一元化して統合することで、環境を脅威から守ります。", - "home.guidedOnboarding.gettingStarted.security.cardTitle": "環境を保護", - "home.guidedOnboarding.gettingStarted.security.iconName": "セキュリティロゴ", "home.guidedOnboarding.gettingStarted.skip.buttonLabel": "いいえ、結構です。自分で探します。", "home.guidedOnboarding.gettingStarted.useCaseSelectionSubtitle": "まず、スタートとしてクイックガイドを表示すると、どのようにElasticでデータに対して高度な操作を実行するのかを確認できます。", "home.guidedOnboarding.gettingStarted.useCaseSelectionTitle": "最初に何をしたいですか?", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d80ac7cc7ffe..143b841f41e5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3102,15 +3102,6 @@ "home.breadcrumbs.integrationsAppTitle": "集成", "home.exploreButtonLabel": "自己浏览", "home.exploreYourDataDescription": "完成所有步骤后,您便可以随时浏览自己的数据。", - "home.guidedOnboarding.gettingStarted.observability.cardDescription": "通过整合您的日志、指标和跟踪,在您的环境中实现端到端可观测性。", - "home.guidedOnboarding.gettingStarted.observability.cardTitle": "监测我的环境", - "home.guidedOnboarding.gettingStarted.observability.iconName": "Observability 徽标", - "home.guidedOnboarding.gettingStarted.search.cardDescription": "为您的网站、应用程序、工作区内容等创建经过优化的搜索体验。", - "home.guidedOnboarding.gettingStarted.search.cardTitle": "搜索我的数据", - "home.guidedOnboarding.gettingStarted.search.iconName": "Enterprise Search 徽标", - "home.guidedOnboarding.gettingStarted.security.cardDescription": "通过在一个位置整合 SIEM、Endpoint Security 和云安全来保护您的环境,防止其受到威胁。", - "home.guidedOnboarding.gettingStarted.security.cardTitle": "保护我的环境", - "home.guidedOnboarding.gettingStarted.security.iconName": "安全徽标", "home.guidedOnboarding.gettingStarted.skip.buttonLabel": "不用了,谢谢,我会自己浏览。", "home.guidedOnboarding.gettingStarted.useCaseSelectionSubtitle": "选择快速教程的起点,了解 Elastic 如何帮助您利用数据完成更多任务。", "home.guidedOnboarding.gettingStarted.useCaseSelectionTitle": "您希望先做什么?", diff --git a/yarn.lock b/yarn.lock index 1bf736f1aa39..00d479810e66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3493,6 +3493,10 @@ version "0.0.0" uid "" +"@kbn/guided-onboarding@link:bazel-bin/packages/kbn-guided-onboarding": + version "0.0.0" + uid "" + "@kbn/handlebars@link:bazel-bin/packages/kbn-handlebars": version "0.0.0" uid "" @@ -7665,6 +7669,10 @@ version "0.0.0" uid "" +"@types/kbn__guided-onboarding@link:bazel-bin/packages/kbn-guided-onboarding/npm_module_types": + version "0.0.0" + uid "" + "@types/kbn__handlebars@link:bazel-bin/packages/kbn-handlebars/npm_module_types": version "0.0.0" uid "" From f61dec4c3d0ce3ca3669c1bcb40adced259b93a7 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 20 Oct 2022 11:41:35 +0200 Subject: [PATCH 29/43] [ML] Fix date picker for the Log pattern analysis (#143684) * url state provider * update url state provider for explain_log_rate_spikes_app_state.tsx --- .../explain_log_rate_spikes_app_state.tsx | 85 +------------------ .../log_categorization_app_state.tsx | 5 +- .../{use_url_state.ts => use_url_state.tsx} | 80 ++++++++++++++++- 3 files changed, 86 insertions(+), 84 deletions(-) rename x-pack/plugins/aiops/public/hooks/{use_url_state.ts => use_url_state.tsx} (63%) diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_app_state.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_app_state.tsx index 34460240d013..ba69fdfa8a7b 100644 --- a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_app_state.tsx +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_app_state.tsx @@ -5,11 +5,7 @@ * 2.0. */ -import React, { FC, useCallback } from 'react'; -import { parse, stringify } from 'query-string'; -import { isEqual } from 'lodash'; -import { encode } from 'rison-node'; -import { useHistory, useLocation } from 'react-router-dom'; +import React, { FC } from 'react'; import { EuiCallOut } from '@elastic/eui'; @@ -24,15 +20,7 @@ import { SearchQueryLanguage, SavedSearchSavedObject, } from '../../application/utils/search_utils'; -import { - Accessor, - Dictionary, - parseUrlState, - Provider as UrlStateContextProvider, - isRisonSerializationRequired, - getNestedProperty, - SetUrlState, -} from '../../hooks/use_url_state'; +import { UrlStateProvider } from '../../hooks/use_url_state'; import type { AiopsAppDependencies } from '../../hooks/use_aiops_app_context'; import { AiopsAppContext } from '../../hooks/use_aiops_app_context'; @@ -77,71 +65,6 @@ export const ExplainLogRateSpikesAppState: FC savedSearch, appDependencies, }) => { - const history = useHistory(); - const { search: urlSearchString } = useLocation(); - - const setUrlState: SetUrlState = useCallback( - ( - accessor: Accessor, - attribute: string | Dictionary, - value?: any, - replaceState?: boolean - ) => { - const prevSearchString = urlSearchString; - const urlState = parseUrlState(prevSearchString); - const parsedQueryString = parse(prevSearchString, { sort: false }); - - if (!Object.prototype.hasOwnProperty.call(urlState, accessor)) { - urlState[accessor] = {}; - } - - if (typeof attribute === 'string') { - if (isEqual(getNestedProperty(urlState, `${accessor}.${attribute}`), value)) { - return prevSearchString; - } - - urlState[accessor][attribute] = value; - } else { - const attributes = attribute; - Object.keys(attributes).forEach((a) => { - urlState[accessor][a] = attributes[a]; - }); - } - - try { - const oldLocationSearchString = stringify(parsedQueryString, { - sort: false, - encode: false, - }); - - Object.keys(urlState).forEach((a) => { - if (isRisonSerializationRequired(a)) { - parsedQueryString[a] = encode(urlState[a]); - } else { - parsedQueryString[a] = urlState[a]; - } - }); - const newLocationSearchString = stringify(parsedQueryString, { - sort: false, - encode: false, - }); - - if (oldLocationSearchString !== newLocationSearchString) { - const newSearchString = stringify(parsedQueryString, { sort: false }); - if (replaceState) { - history.replace({ search: newSearchString }); - } else { - history.push({ search: newSearchString }); - } - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Could not save url state', error); - } - }, - [history, urlSearchString] - ); - if (!dataView) return null; if (!dataView.isTimeBased()) { @@ -165,11 +88,11 @@ export const ExplainLogRateSpikesAppState: FC return ( - + - + ); }; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_app_state.tsx b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_app_state.tsx index 1a546f5ec60b..bc67b0f32eda 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_app_state.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_app_state.tsx @@ -11,6 +11,7 @@ import { LogCategorizationPage } from './log_categorization_page'; import { SavedSearchSavedObject } from '../../application/utils/search_utils'; import type { AiopsAppDependencies } from '../../hooks/use_aiops_app_context'; import { AiopsAppContext } from '../../hooks/use_aiops_app_context'; +import { UrlStateProvider } from '../../hooks/use_url_state'; export interface LogCategorizationAppStateProps { dataView: DataView; @@ -25,7 +26,9 @@ export const LogCategorizationAppState: FC = ({ }) => { return ( - + + + ); }; diff --git a/x-pack/plugins/aiops/public/hooks/use_url_state.ts b/x-pack/plugins/aiops/public/hooks/use_url_state.tsx similarity index 63% rename from x-pack/plugins/aiops/public/hooks/use_url_state.ts rename to x-pack/plugins/aiops/public/hooks/use_url_state.tsx index 21d58c5766c5..e6564ac72f9b 100644 --- a/x-pack/plugins/aiops/public/hooks/use_url_state.ts +++ b/x-pack/plugins/aiops/public/hooks/use_url_state.tsx @@ -5,9 +5,12 @@ * 2.0. */ -import { parse } from 'query-string'; +import React, { FC } from 'react'; +import { parse, stringify } from 'query-string'; import { createContext, useCallback, useContext, useMemo } from 'react'; -import { decode } from 'rison-node'; +import { decode, encode } from 'rison-node'; +import { useHistory, useLocation } from 'react-router-dom'; +import { isEqual } from 'lodash'; export interface Dictionary { [id: string]: TValue; @@ -87,6 +90,79 @@ export const aiopsUrlStateStore = createContext({ export const { Provider } = aiopsUrlStateStore; +export const UrlStateProvider: FC = ({ children }) => { + const { Provider: StateProvider } = aiopsUrlStateStore; + + const history = useHistory(); + const { search: urlSearchString } = useLocation(); + + const setUrlState: SetUrlState = useCallback( + ( + accessor: Accessor, + attribute: string | Dictionary, + value?: any, + replaceState?: boolean + ) => { + const prevSearchString = urlSearchString; + const urlState = parseUrlState(prevSearchString); + const parsedQueryString = parse(prevSearchString, { sort: false }); + + if (!Object.prototype.hasOwnProperty.call(urlState, accessor)) { + urlState[accessor] = {}; + } + + if (typeof attribute === 'string') { + if (isEqual(getNestedProperty(urlState, `${accessor}.${attribute}`), value)) { + return prevSearchString; + } + + urlState[accessor][attribute] = value; + } else { + const attributes = attribute; + Object.keys(attributes).forEach((a) => { + urlState[accessor][a] = attributes[a]; + }); + } + + try { + const oldLocationSearchString = stringify(parsedQueryString, { + sort: false, + encode: false, + }); + + Object.keys(urlState).forEach((a) => { + if (isRisonSerializationRequired(a)) { + parsedQueryString[a] = encode(urlState[a]); + } else { + parsedQueryString[a] = urlState[a]; + } + }); + const newLocationSearchString = stringify(parsedQueryString, { + sort: false, + encode: false, + }); + + if (oldLocationSearchString !== newLocationSearchString) { + const newSearchString = stringify(parsedQueryString, { sort: false }); + if (replaceState) { + history.replace({ search: newSearchString }); + } else { + history.push({ search: newSearchString }); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Could not save url state', error); + } + }, + [history, urlSearchString] + ); + + return ( + {children} + ); +}; + export const useUrlState = (accessor: Accessor) => { const { searchString, setUrlState: setUrlStateContext } = useContext(aiopsUrlStateStore); From 1ec2edf4f4c3608a5a203de41f9d648ac7c5028e Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 20 Oct 2022 12:18:36 +0200 Subject: [PATCH 30/43] [Search] Expose `data.search.asyncSearch.pollInterval` (#143508) --- .../resources/base/bin/kibana-docker | 1 + src/plugins/data/config.ts | 21 +++++++++++++++++++ .../search_interceptor.test.ts | 2 ++ .../search_interceptor/search_interceptor.ts | 3 +++ .../data/public/search/search_service.ts | 1 + .../eql_search/eql_search_strategy.ts | 5 ++++- .../ese_search/ese_search_strategy.ts | 5 ++++- .../sql_search/sql_search_strategy.ts | 5 ++++- .../test_suites/core_plugins/rendering.ts | 1 + 9 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index ff13005aaf92..377bffcfdacc 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -44,6 +44,7 @@ kibana_vars=( data.search.asyncSearch.waitForCompletion data.search.asyncSearch.keepAlive data.search.asyncSearch.batchedReduceSize + data.search.asyncSearch.pollInterval data.search.sessions.defaultExpiration data.search.sessions.enabled data.search.sessions.maxUpdateRetries diff --git a/src/plugins/data/config.ts b/src/plugins/data/config.ts index 05d52b4e127b..688f65038aa1 100644 --- a/src/plugins/data/config.ts +++ b/src/plugins/data/config.ts @@ -47,10 +47,31 @@ export const searchSessionsConfigSchema = schema.object({ }); export const searchConfigSchema = schema.object({ + /** + * Config for search strategies that use async search based API underneath + */ asyncSearch: schema.object({ + /** + * Block and wait until the search is completed up to the timeout (see es async_search's `wait_for_completion_timeout`) + * TODO: we should optimize this as 100ms is likely not optimal (https://github.com/elastic/kibana/issues/143277) + */ waitForCompletion: schema.duration({ defaultValue: '100ms' }), + /** + * How long the async search needs to be available after each search poll. Ongoing async searches and any saved search results are deleted after this period. + * (see es async_search's `keep_alive`) + * Note: This is applicable to the searches before the search session is saved. + * After search session is saved `keep_alive` is extended using `data.search.sessions.defaultExpiration` config + */ keepAlive: schema.duration({ defaultValue: '1m' }), + /** + * Affects how often partial results become available, which happens whenever shard results are reduced (see es async_search's `batched_reduce_size`) + */ batchedReduceSize: schema.number({ defaultValue: 64 }), + /** + * How long to wait before polling the async_search after the previous poll response. + * If not provided, then default dynamic interval with backoff is used. + */ + pollInterval: schema.maybe(schema.number({ min: 1000 })), }), aggs: schema.object({ shardDelay: schema.object({ diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts index 73c86d8845a1..35bbecfdb5c4 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts @@ -34,6 +34,7 @@ jest.mock('../errors/search_session_incomplete_warning', () => ({ })); import { SearchSessionIncompleteWarning } from '../errors/search_session_incomplete_warning'; +import { getMockSearchConfig } from '../../../config.mock'; let searchInterceptor: SearchInterceptor; let mockCoreSetup: MockedKeys; @@ -122,6 +123,7 @@ describe('SearchInterceptor', () => { executionContext: mockCoreSetup.executionContext, session: sessionService, theme: themeServiceMock.createSetupContract(), + searchConfig: getMockSearchConfig({}), }); }); diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts index baa5eb30bca4..f88387e4e44e 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts @@ -72,6 +72,7 @@ import { ISessionService, SearchSessionState } from '../session'; import { SearchResponseCache } from './search_response_cache'; import { createRequestHash } from './utils'; import { SearchAbortController } from './search_abort_controller'; +import { SearchConfigSchema } from '../../../config'; export interface SearchInterceptorDeps { bfetch: BfetchPublicSetup; @@ -83,6 +84,7 @@ export interface SearchInterceptorDeps { usageCollector?: SearchUsageCollector; session: ISessionService; theme: ThemeServiceSetup; + searchConfig: SearchConfigSchema; } const MAX_CACHE_ITEMS = 50; @@ -302,6 +304,7 @@ export class SearchInterceptor { }); return pollSearch(search, cancel, { + pollInterval: this.deps.searchConfig.asyncSearch.pollInterval, ...options, abortSignal: searchAbortController.getSignal(), }).pipe( diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index e7b753838edf..dbba423c8ea8 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -134,6 +134,7 @@ export class SearchService implements Plugin { usageCollector: this.usageCollector!, session: this.sessionService, theme, + searchConfig: this.initializerContext.config.get().search, }); expressions.registerFunction( diff --git a/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts b/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts index 14b5c76c67f4..d6f5d948c784 100644 --- a/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts @@ -86,7 +86,10 @@ export const eqlSearchStrategyProvider = ( } }; - return pollSearch(search, cancel, options).pipe(tap((response) => (id = response.id))); + return pollSearch(search, cancel, { + pollInterval: searchConfig.asyncSearch.pollInterval, + ...options, + }).pipe(tap((response) => (id = response.id))); }, extend: async (id, keepAlive, options, { esClient }) => { diff --git a/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts b/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts index fb463bbcabad..e5567b45f1e0 100644 --- a/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts @@ -87,7 +87,10 @@ export const enhancedEsSearchStrategyProvider = ( } }; - return pollSearch(search, cancel, options).pipe( + return pollSearch(search, cancel, { + pollInterval: searchConfig.asyncSearch.pollInterval, + ...options, + }).pipe( tap((response) => (id = response.id)), tap(searchUsageObserver(logger, usage)), catchError((e) => { diff --git a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts index 64c4d6fd5ee0..c8928a343eec 100644 --- a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts @@ -88,7 +88,10 @@ export const sqlSearchStrategyProvider = ( } }; - return pollSearch(search, cancel, options).pipe( + return pollSearch(search, cancel, { + pollInterval: searchConfig.asyncSearch.pollInterval, + ...options, + }).pipe( tap((response) => (id = response.id)), catchError((e) => { throw getKbnServerError(e); diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index cb9b70808099..1c7e8a96bd1e 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -94,6 +94,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'data.search.asyncSearch.batchedReduceSize (number)', 'data.search.asyncSearch.keepAlive (duration)', 'data.search.asyncSearch.waitForCompletion (duration)', + 'data.search.asyncSearch.pollInterval (number)', 'data.search.sessions.defaultExpiration (duration)', 'data.search.sessions.enabled (boolean)', 'data.search.sessions.management.expiresSoonWarning (duration)', From 7ba95aca63fae9af621f6a7e1e02470bc91b226a Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 20 Oct 2022 12:24:59 +0200 Subject: [PATCH 31/43] Search Sessions improvements for debugging and error state (#143550) --- .../data/common/search/session/types.ts | 2 ++ .../components/actions/delete_button.tsx | 2 +- .../components/actions/inspect_button.tsx | 2 +- .../sessions_mgmt/components/status.test.tsx | 25 +++++++++++++++++ .../sessions_mgmt/components/status.tsx | 13 ++++++--- .../session/sessions_mgmt/lib/api.test.ts | 6 +++-- .../search/session/sessions_mgmt/lib/api.ts | 18 +++++++------ .../sessions_mgmt/lib/get_columns.test.tsx | 1 + .../search/session/sessions_mgmt/types.ts | 2 ++ .../search/session/get_search_status.ts | 7 ++--- .../search/session/get_session_status.test.ts | 27 ++++++++++++++----- .../search/session/get_session_status.ts | 14 +++++----- .../server/search/session/session_service.ts | 6 ++--- .../translations/translations/fr-FR.json | 3 --- .../translations/translations/ja-JP.json | 3 --- .../translations/translations/zh-CN.json | 3 --- 16 files changed, 90 insertions(+), 44 deletions(-) diff --git a/src/plugins/data/common/search/session/types.ts b/src/plugins/data/common/search/session/types.ts index 7824a1db6362..0f2e14be1957 100644 --- a/src/plugins/data/common/search/session/types.ts +++ b/src/plugins/data/common/search/session/types.ts @@ -91,6 +91,8 @@ export interface SearchSessionRequestStatus { */ export interface SearchSessionStatusResponse { status: SearchSessionStatus; + + errors?: string[]; } /** diff --git a/src/plugins/data/public/search/session/sessions_mgmt/components/actions/delete_button.tsx b/src/plugins/data/public/search/session/sessions_mgmt/components/actions/delete_button.tsx index 0581af4a872f..39c8b8d0fc38 100644 --- a/src/plugins/data/public/search/session/sessions_mgmt/components/actions/delete_button.tsx +++ b/src/plugins/data/public/search/session/sessions_mgmt/components/actions/delete_button.tsx @@ -49,7 +49,7 @@ const DeleteConfirm = (props: DeleteButtonProps & { onActionDismiss: OnActionDis onCancel={onActionDismiss} onConfirm={async () => { setIsLoading(true); - await api.sendCancel(id); + await api.sendDelete(id); onActionDismiss(); }} confirmButtonText={confirm} diff --git a/src/plugins/data/public/search/session/sessions_mgmt/components/actions/inspect_button.tsx b/src/plugins/data/public/search/session/sessions_mgmt/components/actions/inspect_button.tsx index 4ba404e70866..6776acff2015 100644 --- a/src/plugins/data/public/search/session/sessions_mgmt/components/actions/inspect_button.tsx +++ b/src/plugins/data/public/search/session/sessions_mgmt/components/actions/inspect_button.tsx @@ -35,7 +35,7 @@ const InspectFlyout = ({ uiSettings, searchSession }: InspectFlyoutProps) => { { initialState: {}, restoreState: {}, version: '8.0.0', + idMapping: {}, }; }); @@ -119,6 +120,30 @@ describe('Background Search Session management status labels', () => { expect(label.text()).toBe('Expired'); }); + test('error', () => { + session.status = SearchSessionStatus.ERROR; + + const statusIndicator = mount( + + + + ); + + const label = statusIndicator + .find(`[data-test-subj="sessionManagementStatusLabel"][data-test-status="error"]`) + .first(); + expect(label.text()).toBe('Error'); + + const tooltip = statusIndicator.find('EuiToolTip'); + expect((tooltip.first().props() as EuiToolTipProps).content).toMatchInlineSnapshot( + `"One or more searches failed to complete. Use the \\"Inspect\\" action to see the underlying errors."` + ); + }); + test('error handling', () => { session.status = SearchSessionStatus.COMPLETE; (session as any).created = null; diff --git a/src/plugins/data/public/search/session/sessions_mgmt/components/status.tsx b/src/plugins/data/public/search/session/sessions_mgmt/components/status.tsx index bb41ff05c109..68dec9750cf2 100644 --- a/src/plugins/data/public/search/session/sessions_mgmt/components/status.tsx +++ b/src/plugins/data/public/search/session/sessions_mgmt/components/status.tsx @@ -116,10 +116,15 @@ const getStatusAttributes = ({ textColor: 'danger', icon: , label: {getStatusText(session.status)}, - toolTipContent: i18n.translate('data.mgmt.searchSessions.status.message.error', { - defaultMessage: 'Error: {error}', - values: { error: (session as any).error || 'unknown' }, - }), + toolTipContent: + session.errors && session.errors.length > 0 + ? i18n.translate('data.mgmt.searchSessions.status.message.error', { + defaultMessage: + 'One or more searches failed to complete. Use the "Inspect" action to see the underlying errors.', + }) + : i18n.translate('data.mgmt.searchSessions.status.message.unknownError', { + defaultMessage: 'Unknown error', + }), }; case SearchSessionStatus.COMPLETE: diff --git a/src/plugins/data/public/search/session/sessions_mgmt/lib/api.test.ts b/src/plugins/data/public/search/session/sessions_mgmt/lib/api.test.ts index 73e1e8dc0866..52b72afd30ab 100644 --- a/src/plugins/data/public/search/session/sessions_mgmt/lib/api.test.ts +++ b/src/plugins/data/public/search/session/sessions_mgmt/lib/api.test.ts @@ -80,8 +80,10 @@ describe('Search Sessions Management API', () => { ], "appId": "pizza", "created": undefined, + "errors": undefined, "expires": undefined, "id": "hello-pizza-123", + "idMapping": Array [], "initialState": Object {}, "name": "Veggie", "numSearches": 0, @@ -192,7 +194,7 @@ describe('Search Sessions Management API', () => { notifications: mockCoreStart.notifications, application: mockCoreStart.application, }); - await api.sendCancel('abc-123-cool-session-ID'); + await api.sendDelete('abc-123-cool-session-ID'); expect(mockCoreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith({ title: 'The search session was deleted.', @@ -207,7 +209,7 @@ describe('Search Sessions Management API', () => { notifications: mockCoreStart.notifications, application: mockCoreStart.application, }); - await api.sendCancel('abc-123-cool-session-ID'); + await api.sendDelete('abc-123-cool-session-ID'); expect(mockCoreStart.notifications.toasts.addError).toHaveBeenCalledWith( new Error('implementation is so bad'), diff --git a/src/plugins/data/public/search/session/sessions_mgmt/lib/api.ts b/src/plugins/data/public/search/session/sessions_mgmt/lib/api.ts index 4fac2d36203c..ee5b4d504e29 100644 --- a/src/plugins/data/public/search/session/sessions_mgmt/lib/api.ts +++ b/src/plugins/data/public/search/session/sessions_mgmt/lib/api.ts @@ -35,7 +35,11 @@ function getActions(status: UISearchSessionState) { actions.push(ACTION.DELETE); } - if (status === SearchSessionStatus.EXPIRED) { + if ( + status === SearchSessionStatus.EXPIRED || + status === SearchSessionStatus.ERROR || + status === SearchSessionStatus.CANCELLED + ) { actions.push(ACTION.DELETE); } @@ -77,6 +81,7 @@ const mapToUISession = } = savedObject.attributes; const status = sessionStatuses[savedObject.id]?.status; + const errors = sessionStatuses[savedObject.id]?.errors; const actions = getActions(status); // TODO: initialState should be saved without the searchSessionID @@ -97,8 +102,10 @@ const mapToUISession = reloadUrl: reloadUrl!, initialState, restoreState, + idMapping, numSearches: Object.keys(idMapping).length, version, + errors, }; }; @@ -165,17 +172,12 @@ export class SearchSessionsMgmtAPI { return []; } - public reloadSearchSession(reloadUrl: string) { - this.deps.usageCollector?.trackSessionReloaded(); - this.deps.application.navigateToUrl(reloadUrl); - } - public getExtendByDuration() { return this.config.defaultExpiration; } - // Cancel and expire - public async sendCancel(id: string): Promise { + // Delete and expire + public async sendDelete(id: string): Promise { this.deps.usageCollector?.trackSessionDeleted(); try { await this.sessionsClient.delete(id); diff --git a/src/plugins/data/public/search/session/sessions_mgmt/lib/get_columns.test.tsx b/src/plugins/data/public/search/session/sessions_mgmt/lib/get_columns.test.tsx index 690713ac9232..3fd53ccfddec 100644 --- a/src/plugins/data/public/search/session/sessions_mgmt/lib/get_columns.test.tsx +++ b/src/plugins/data/public/search/session/sessions_mgmt/lib/get_columns.test.tsx @@ -77,6 +77,7 @@ describe('Search Sessions Management table column factory', () => { initialState: {}, restoreState: {}, version: '7.14.0', + idMapping: {}, }; }); diff --git a/src/plugins/data/public/search/session/sessions_mgmt/types.ts b/src/plugins/data/public/search/session/sessions_mgmt/types.ts index d21b4371099c..9ec4a2e013fa 100644 --- a/src/plugins/data/public/search/session/sessions_mgmt/types.ts +++ b/src/plugins/data/public/search/session/sessions_mgmt/types.ts @@ -32,6 +32,7 @@ export interface UISession { created: string; expires: string | null; status: UISearchSessionState; + idMapping: SearchSessionSavedObjectAttributes['idMapping']; numSearches: number; actions?: ACTION[]; reloadUrl: string; @@ -39,4 +40,5 @@ export interface UISession { initialState: Record; restoreState: Record; version: string; + errors?: string[]; } diff --git a/src/plugins/data/server/search/session/get_search_status.ts b/src/plugins/data/server/search/session/get_search_status.ts index fb34fff1c1d4..f9b14fefdf39 100644 --- a/src/plugins/data/server/search/session/get_search_status.ts +++ b/src/plugins/data/server/search/session/get_search_status.ts @@ -33,8 +33,8 @@ export async function getSearchStatus( return { status: SearchStatus.ERROR, error: i18n.translate('data.search.statusError', { - defaultMessage: `Search completed with a {errorCode} status`, - values: { errorCode: response.completion_status }, + defaultMessage: `Search {searchId} completed with a {errorCode} status`, + values: { searchId: asyncId, errorCode: response.completion_status }, }), }; } else if (!response.is_partial && !response.is_running) { @@ -52,10 +52,11 @@ export async function getSearchStatus( return { status: SearchStatus.ERROR, error: i18n.translate('data.search.statusThrow', { - defaultMessage: `Search status threw an error {message} ({errorCode}) status`, + defaultMessage: `Search status for search with id {searchId} threw an error {message} (statusCode: {errorCode})`, values: { message: e.message, errorCode: e.statusCode || 500, + searchId: asyncId, }, }), }; diff --git a/src/plugins/data/server/search/session/get_session_status.test.ts b/src/plugins/data/server/search/session/get_session_status.test.ts index b7e323a15f06..1d59fd11c471 100644 --- a/src/plugins/data/server/search/session/get_session_status.test.ts +++ b/src/plugins/data/server/search/session/get_session_status.test.ts @@ -47,7 +47,9 @@ describe('getSessionStatus', () => { idMapping: {}, touched: moment(), }; - expect(await getSessionStatus(deps, session, mockConfig)).toBe(SearchSessionStatus.IN_PROGRESS); + expect(await getSessionStatus(deps, session, mockConfig)).toEqual({ + status: SearchSessionStatus.IN_PROGRESS, + }); }); test("returns an error status if there's at least one error", async () => { @@ -74,7 +76,10 @@ describe('getSessionStatus', () => { c: { id: 'c' }, }, }; - expect(await getSessionStatus(deps, session, mockConfig)).toBe(SearchSessionStatus.ERROR); + expect(await getSessionStatus(deps, session, mockConfig)).toEqual({ + status: SearchSessionStatus.ERROR, + errors: ['Search b completed with a 500 status'], + }); }); test('expires a session if expired < now', async () => { @@ -83,7 +88,9 @@ describe('getSessionStatus', () => { expires: moment().subtract(2, 'm'), }; - expect(await getSessionStatus(deps, session, mockConfig)).toBe(SearchSessionStatus.EXPIRED); + expect(await getSessionStatus(deps, session, mockConfig)).toEqual({ + status: SearchSessionStatus.EXPIRED, + }); }); test('doesnt expire if expire > now', async () => { @@ -95,7 +102,9 @@ describe('getSessionStatus', () => { }, expires: moment().add(2, 'm'), }; - expect(await getSessionStatus(deps, session, mockConfig)).toBe(SearchSessionStatus.IN_PROGRESS); + expect(await getSessionStatus(deps, session, mockConfig)).toEqual({ + status: SearchSessionStatus.IN_PROGRESS, + }); }); test('returns cancelled status if session was cancelled', async () => { @@ -108,7 +117,7 @@ describe('getSessionStatus', () => { }; expect( await getSessionStatus(deps, session as SearchSessionSavedObjectAttributes, mockConfig) - ).toBe(SearchSessionStatus.CANCELLED); + ).toEqual({ status: SearchSessionStatus.CANCELLED }); }); test('returns a complete status if all are complete', async () => { @@ -121,7 +130,9 @@ describe('getSessionStatus', () => { c: { id: 'c' }, }, }; - expect(await getSessionStatus(deps, session, mockConfig)).toBe(SearchSessionStatus.COMPLETE); + expect(await getSessionStatus(deps, session, mockConfig)).toEqual({ + status: SearchSessionStatus.COMPLETE, + }); }); test('returns a running status if some are still running', async () => { @@ -141,6 +152,8 @@ describe('getSessionStatus', () => { c: { id: 'c' }, }, }; - expect(await getSessionStatus(deps, session, mockConfig)).toBe(SearchSessionStatus.IN_PROGRESS); + expect(await getSessionStatus(deps, session, mockConfig)).toEqual({ + status: SearchSessionStatus.IN_PROGRESS, + }); }); }); diff --git a/src/plugins/data/server/search/session/get_session_status.ts b/src/plugins/data/server/search/session/get_session_status.ts index b89b4c487c32..e1cb50a6c4cd 100644 --- a/src/plugins/data/server/search/session/get_session_status.ts +++ b/src/plugins/data/server/search/session/get_session_status.ts @@ -17,15 +17,15 @@ export async function getSessionStatus( deps: { internalClient: ElasticsearchClient }, session: SearchSessionSavedObjectAttributes, config: SearchSessionsConfigSchema -): Promise { +): Promise<{ status: SearchSessionStatus; errors?: string[] }> { if (session.isCanceled === true) { - return SearchSessionStatus.CANCELLED; + return { status: SearchSessionStatus.CANCELLED }; } const now = moment(); if (moment(session.expires).isBefore(now)) { - return SearchSessionStatus.EXPIRED; + return { status: SearchSessionStatus.EXPIRED }; } const searches = Object.values(session.idMapping); @@ -40,13 +40,15 @@ export async function getSessionStatus( ); if (searchStatuses.some((item) => item.status === SearchStatus.ERROR)) { - return SearchSessionStatus.ERROR; + const erroredSearches = searchStatuses.filter((s) => s.status === SearchStatus.ERROR); + const errors = erroredSearches.map((s) => s.error).filter((error) => !!error) as string[]; + return { status: SearchSessionStatus.ERROR, errors }; } else if ( searchStatuses.length > 0 && searchStatuses.every((item) => item.status === SearchStatus.COMPLETE) ) { - return SearchSessionStatus.COMPLETE; + return { status: SearchSessionStatus.COMPLETE }; } else { - return SearchSessionStatus.IN_PROGRESS; + return { status: SearchSessionStatus.IN_PROGRESS }; } } diff --git a/src/plugins/data/server/search/session/session_service.ts b/src/plugins/data/server/search/session/session_service.ts index d8426c03e91e..45b0a3167840 100644 --- a/src/plugins/data/server/search/session/session_service.ts +++ b/src/plugins/data/server/search/session/session_service.ts @@ -208,8 +208,8 @@ export class SearchSessionService implements ISearchSessionService { return { ...findResponse, statuses: sessionStatuses.reduce>( - (res, status, index) => { - res[findResponse.saved_objects[index].id] = { status }; + (res, { status, errors }, index) => { + res[findResponse.saved_objects[index].id] = { status, errors }; return res; }, {} @@ -380,7 +380,7 @@ export class SearchSessionService implements ISearchSessionService { this.sessionConfig ); - return { status: sessionStatus }; + return { status: sessionStatus.status, errors: sessionStatus.errors }; } /** diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 28fa7768616e..844e40db8b25 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -1051,7 +1051,6 @@ "data.mgmt.searchSessions.status.expiresSoonInHours": "Cette session expire dans {numHours} heures", "data.mgmt.searchSessions.status.expiresSoonInHoursTooltip": "{numHours} heures", "data.mgmt.searchSessions.status.message.createdOn": "Expire le {expireDate}", - "data.mgmt.searchSessions.status.message.error": "Erreur : {error}", "data.mgmt.searchSessions.status.message.expiredOn": "Expiré le {expireDate}", "data.painlessError.painlessScriptedFieldErrorMessage": "Erreur d'exécution du champ d'exécution ou du champ scripté sur le modèle d'indexation {indexPatternName}", "data.parseEsInterval.invalidEsCalendarIntervalErrorMessage": "Intervalle de calendrier non valide : {interval} ; la valeur doit être 1.", @@ -1097,8 +1096,6 @@ "data.search.searchSource.indexPatternIdDescription": "L'ID dans l'index {kibanaIndexPattern}.", "data.search.searchSource.queryTimeValue": "{queryTime} ms", "data.search.searchSource.requestTimeValue": "{requestTime} ms", - "data.search.statusError": "Recherche terminée avec un statut {errorCode}", - "data.search.statusThrow": "Le statut de la recherche a généré un statut d'erreur {message} ({errorCode})", "data.search.timeBuckets.dayLabel": "{amount, plural, one {un jour} other {# jours}}", "data.search.timeBuckets.hourLabel": "{amount, plural, one {une heure} other {# heures}}", "data.search.timeBuckets.millisecondLabel": "{amount, plural, one {une milliseconde} other {# millisecondes}}", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4a7a973e26e3..23b7ddf6ff13 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1049,7 +1049,6 @@ "data.mgmt.searchSessions.status.expiresSoonInHours": "このセッションは{numHours}時間後に期限切れになります", "data.mgmt.searchSessions.status.expiresSoonInHoursTooltip": "{numHours}時間", "data.mgmt.searchSessions.status.message.createdOn": "有効期限:{expireDate}", - "data.mgmt.searchSessions.status.message.error": "エラー:{error}", "data.mgmt.searchSessions.status.message.expiredOn": "有効期限:{expireDate}", "data.painlessError.painlessScriptedFieldErrorMessage": "インデックスパターン{indexPatternName}でのランタイムフィールドまたはスクリプトフィールドの実行エラー", "data.parseEsInterval.invalidEsCalendarIntervalErrorMessage": "無効なカレンダー間隔:{interval}、1よりも大きな値が必要です", @@ -1095,8 +1094,6 @@ "data.search.searchSource.indexPatternIdDescription": "{kibanaIndexPattern} インデックス内の ID です。", "data.search.searchSource.queryTimeValue": "{queryTime}ms", "data.search.searchSource.requestTimeValue": "{requestTime}ms", - "data.search.statusError": "検索は{errorCode}ステータスで完了しました", - "data.search.statusThrow": "検索ステータスはエラー{message}({errorCode})ステータスを返しました", "data.search.timeBuckets.dayLabel": "{amount, plural, other {# 日}}", "data.search.timeBuckets.hourLabel": "{amount, plural, other {# 時間}}", "data.search.timeBuckets.millisecondLabel": "{amount, plural, other {# ミリ秒}}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 143b841f41e5..dd483770f0df 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1051,7 +1051,6 @@ "data.mgmt.searchSessions.status.expiresSoonInHours": "此会话将于 {numHours} 小时后过期", "data.mgmt.searchSessions.status.expiresSoonInHoursTooltip": "{numHours} 小时", "data.mgmt.searchSessions.status.message.createdOn": "于 {expireDate}过期", - "data.mgmt.searchSessions.status.message.error": "错误:{error}", "data.mgmt.searchSessions.status.message.expiredOn": "已于 {expireDate}过期", "data.painlessError.painlessScriptedFieldErrorMessage": "在索引模式 {indexPatternName} 上执行运行时字段或脚本字段时出错", "data.parseEsInterval.invalidEsCalendarIntervalErrorMessage": "无效的日历时间间隔:{interval},值必须为 1", @@ -1097,8 +1096,6 @@ "data.search.searchSource.indexPatternIdDescription": "{kibanaIndexPattern} 索引中的 ID。", "data.search.searchSource.queryTimeValue": "{queryTime}ms", "data.search.searchSource.requestTimeValue": "{requestTime}ms", - "data.search.statusError": "搜索完成,状态为 {errorCode}", - "data.search.statusThrow": "搜索状态引发错误 {message} ({errorCode}) 状态", "data.search.timeBuckets.dayLabel": "{amount, plural, other {# 天}}", "data.search.timeBuckets.hourLabel": "{amount, plural, other {# 小时}}", "data.search.timeBuckets.millisecondLabel": "{amount, plural,other {# 毫秒}}", From 1cbc75e83f8022a60ecc80b08d508b94db6889e5 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Thu, 20 Oct 2022 12:25:34 +0200 Subject: [PATCH 32/43] :bug: Fix empty autocomplete in comparison (#143663) --- .../operations/definitions/formula/editor/formula_editor.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx index fa293a57903f..dcfdebf5c6b8 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx @@ -600,7 +600,9 @@ export function FormulaEditor({ column: currentPosition.startColumn + cursorOffset, lineNumber: currentPosition.startLineNumber, }); - editor.trigger('lens', 'editor.action.triggerSuggest', {}); + if (editOperation?.text !== '=') { + editor.trigger('lens', 'editor.action.triggerSuggest', {}); + } }, 0); } } From ea704af6d4c7e34927579f48d486ee8cce592980 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 20 Oct 2022 12:57:53 +0200 Subject: [PATCH 33/43] [Saved Objects] Added `created_at` field in saved objects (#143507) --- .../src/simple_saved_object.ts | 1 + .../src/lib/included_fields.test.ts | 1 + .../src/lib/included_fields.ts | 1 + .../src/lib/internal_utils.ts | 3 +- .../src/lib/repository.test.ts | 13 +++--- .../src/lib/repository.ts | 2 + .../src/serialization/serializer.test.ts | 42 +++++++++++++++++++ .../src/serialization/serializer.ts | 3 ++ .../src/validation/schema.ts | 1 + .../src/simple_saved_object.ts | 3 ++ .../src/simple_saved_object.mock.ts | 2 + .../src/saved_objects.ts | 2 + .../kibana_migrator.test.ts.snap | 4 ++ .../build_active_mappings.test.ts.snap | 8 ++++ .../src/core/build_active_mappings.ts | 3 ++ .../src/serialization.ts | 2 + .../apis/saved_objects/bulk_create.ts | 1 + .../apis/saved_objects/bulk_get.ts | 4 ++ .../apis/saved_objects/create.ts | 1 + .../apis/saved_objects/export.ts | 3 ++ .../api_integration/apis/saved_objects/get.ts | 1 + .../apis/saved_objects/resolve.ts | 2 + 22 files changed, 97 insertions(+), 6 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/simple_saved_object.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/simple_saved_object.ts index 46d3e65cdec1..61e4359bf3d7 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/simple_saved_object.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/simple_saved_object.ts @@ -27,6 +27,7 @@ export interface SimpleSavedObject { error: SavedObjectType['error']; references: SavedObjectType['references']; updatedAt: SavedObjectType['updated_at']; + createdAt: SavedObjectType['created_at']; /** * Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with * `namespaceType: 'agnostic'`. diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/included_fields.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/included_fields.test.ts index 51c431b1c6b3..279b73aced83 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/included_fields.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/included_fields.test.ts @@ -20,6 +20,7 @@ describe('getRootFields', () => { "migrationVersion", "coreMigrationVersion", "updated_at", + "created_at", "originId", ] `); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/included_fields.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/included_fields.ts index 9613d8f6a4a4..55e68c84da51 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/included_fields.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/included_fields.ts @@ -18,6 +18,7 @@ const ROOT_FIELDS = [ 'migrationVersion', 'coreMigrationVersion', 'updated_at', + 'created_at', 'originId', ]; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_utils.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_utils.ts index cbe209012ff1..73134f4855a3 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_utils.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_utils.ts @@ -131,7 +131,7 @@ export function getSavedObjectFromSource( id: string, doc: { _seq_no?: number; _primary_term?: number; _source: SavedObjectsRawDocSource } ): SavedObject { - const { originId, updated_at: updatedAt } = doc._source; + const { originId, updated_at: updatedAt, created_at: createdAt } = doc._source; let namespaces: string[] = []; if (!registry.isNamespaceAgnostic(type)) { @@ -146,6 +146,7 @@ export function getSavedObjectFromSource( namespaces, ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), + ...(createdAt && { created_at: createdAt }), version: encodeHitVersion(doc), attributes: doc._source[type], references: doc._source.references || [], diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts index d5b2ed687433..8c0690bfdf4f 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts @@ -112,6 +112,7 @@ describe('SavedObjectsRepository', () => { const mockTimestamp = '2017-08-14T15:49:14.886Z'; const mockTimestampFields = { updated_at: mockTimestamp }; + const mockTimestampFieldsWithCreated = { updated_at: mockTimestamp, created_at: mockTimestamp }; const mockVersionProps = { _seq_no: 1, _primary_term: 1 }; const mockVersion = encodeHitVersion(mockVersionProps); @@ -454,7 +455,7 @@ describe('SavedObjectsRepository', () => { namespace, ...(originId && { originId }), references, - ...mockTimestampFields, + ...mockTimestampFieldsWithCreated, migrationVersion: migrationVersion || { [type]: '1.1.1' }, }, ...mockVersionProps, @@ -528,7 +529,7 @@ describe('SavedObjectsRepository', () => { coreMigrationVersion: KIBANA_VERSION, version: mockVersion, namespaces: obj.namespaces ?? [obj.namespace ?? 'default'], - ...mockTimestampFields, + ...mockTimestampFieldsWithCreated, }); describe('client calls', () => { @@ -1089,7 +1090,7 @@ describe('SavedObjectsRepository', () => { migrator.migrateDocument.mockImplementation(mockMigrateDocument); const modifiedObj1 = { ...obj1, coreMigrationVersion: '8.0.0' }; await bulkCreateSuccess([modifiedObj1, obj2]); - const docs = [modifiedObj1, obj2].map((x) => ({ ...x, ...mockTimestampFields })); + const docs = [modifiedObj1, obj2].map((x) => ({ ...x, ...mockTimestampFieldsWithCreated })); expectMigrationArgs(docs[0], true, 1); expectMigrationArgs(docs[1], true, 2); @@ -1440,6 +1441,7 @@ describe('SavedObjectsRepository', () => { namespaces: doc._source!.namespaces ?? ['default'], ...(doc._source!.originId && { originId: doc._source!.originId }), ...(doc._source!.updated_at && { updated_at: doc._source!.updated_at }), + ...(doc._source!.created_at && { created_at: doc._source!.created_at }), version: encodeHitVersion(doc), attributes: doc._source![type], references: doc._source!.references || [], @@ -3233,7 +3235,7 @@ describe('SavedObjectsRepository', () => { references, migrationVersion, coreMigrationVersion, - ...mockTimestampFields, + ...mockTimestampFieldsWithCreated, }; expectMigrationArgs(doc); @@ -3290,7 +3292,7 @@ describe('SavedObjectsRepository', () => { expect(result).toEqual({ type: MULTI_NAMESPACE_TYPE, id, - ...mockTimestampFields, + ...mockTimestampFieldsWithCreated, version: mockVersion, attributes, references, @@ -3969,6 +3971,7 @@ describe('SavedObjectsRepository', () => { 'migrationVersion', 'coreMigrationVersion', 'updated_at', + 'created_at', 'originId', 'title', ], diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.ts index f48e031bd23c..5bf340374262 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.ts @@ -337,6 +337,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { attributes, migrationVersion, coreMigrationVersion, + created_at: time, updated_at: time, ...(Array.isArray(references) && { references }), }); @@ -516,6 +517,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { ...(savedObjectNamespace && { namespace: savedObjectNamespace }), ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), updated_at: time, + created_at: time, references: object.references || [], originId, }) as SavedObjectSanitizedDoc; diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.test.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.test.ts index 230993bd06d2..dae97802578c 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.test.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.test.ts @@ -230,6 +230,18 @@ describe('#rawToSavedObject', () => { expect(actual).toHaveProperty('updated_at', now); }); + test('if specified it copies the _source.created_at property to created_at', () => { + const now = Date(); + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + created_at: now, + }, + }); + expect(actual).toHaveProperty('created_at', now); + }); + test(`if _source.updated_at is unspecified it doesn't set updated_at`, () => { const actual = singleNamespaceSerializer.rawToSavedObject({ _id: 'foo:bar', @@ -240,6 +252,16 @@ describe('#rawToSavedObject', () => { expect(actual).not.toHaveProperty('updated_at'); }); + test(`if _source.created_at is unspecified it doesn't set created_at`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).not.toHaveProperty('created_at'); + }); + test('if specified it copies the _source.originId property to originId', () => { const originId = 'baz'; const actual = singleNamespaceSerializer.rawToSavedObject({ @@ -584,6 +606,26 @@ describe('#savedObjectToRaw', () => { ]); }); + test('if specified it copies the created_at property to _source.created_at', () => { + const now = new Date(); + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + created_at: now, + } as any); + + expect(actual._source).toHaveProperty('created_at', now); + }); + + test(`if unspecified it doesn't add created_at property to _source`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual._source).not.toHaveProperty('created_at'); + }); + test('if specified it copies the updated_at property to _source.updated_at', () => { const now = new Date(); const actual = singleNamespaceSerializer.savedObjectToRaw({ diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.ts index 340926abd0bc..f1e713b8741b 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.ts @@ -110,6 +110,7 @@ export class SavedObjectsSerializer implements ISavedObjectsSerializer { ...(migrationVersion && { migrationVersion }), ...(coreMigrationVersion && { coreMigrationVersion }), ...(_source.updated_at && { updated_at: _source.updated_at }), + ...(_source.created_at && { created_at: _source.created_at }), ...(version && { version }), }; } @@ -130,6 +131,7 @@ export class SavedObjectsSerializer implements ISavedObjectsSerializer { migrationVersion, // eslint-disable-next-line @typescript-eslint/naming-convention updated_at, + created_at: createdAt, version, references, coreMigrationVersion, @@ -144,6 +146,7 @@ export class SavedObjectsSerializer implements ISavedObjectsSerializer { ...(migrationVersion && { migrationVersion }), ...(coreMigrationVersion && { coreMigrationVersion }), ...(updated_at && { updated_at }), + ...(createdAt && { created_at: createdAt }), }; return { diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/validation/schema.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/validation/schema.ts index 221f21b5aa99..8b745caae85d 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/validation/schema.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/validation/schema.ts @@ -43,6 +43,7 @@ export const createSavedObjectSanitizedDocSchema = (attributesSchema: SavedObjec migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())), coreMigrationVersion: schema.maybe(schema.string()), updated_at: schema.maybe(schema.string()), + created_at: schema.maybe(schema.string()), version: schema.maybe(schema.string()), originId: schema.maybe(schema.string()), }); diff --git a/packages/core/saved-objects/core-saved-objects-browser-internal/src/simple_saved_object.ts b/packages/core/saved-objects/core-saved-objects-browser-internal/src/simple_saved_object.ts index adda64d8b4ff..414ed5bbf184 100644 --- a/packages/core/saved-objects/core-saved-objects-browser-internal/src/simple_saved_object.ts +++ b/packages/core/saved-objects/core-saved-objects-browser-internal/src/simple_saved_object.ts @@ -29,6 +29,7 @@ export class SimpleSavedObjectImpl implements SimpleSavedObject public error: SavedObjectType['error']; public references: SavedObjectType['references']; public updatedAt: SavedObjectType['updated_at']; + public createdAt: SavedObjectType['created_at']; public namespaces: SavedObjectType['namespaces']; constructor( @@ -44,6 +45,7 @@ export class SimpleSavedObjectImpl implements SimpleSavedObject coreMigrationVersion, namespaces, updated_at: updatedAt, + created_at: createdAt, }: SavedObjectType ) { this.id = id; @@ -55,6 +57,7 @@ export class SimpleSavedObjectImpl implements SimpleSavedObject this.coreMigrationVersion = coreMigrationVersion; this.namespaces = namespaces; this.updatedAt = updatedAt; + this.createdAt = createdAt; if (error) { this.error = error; } diff --git a/packages/core/saved-objects/core-saved-objects-browser-mocks/src/simple_saved_object.mock.ts b/packages/core/saved-objects/core-saved-objects-browser-mocks/src/simple_saved_object.mock.ts index b65c476c54ee..2e3c30ac17d9 100644 --- a/packages/core/saved-objects/core-saved-objects-browser-mocks/src/simple_saved_object.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-browser-mocks/src/simple_saved_object.mock.ts @@ -24,6 +24,7 @@ const simpleSavedObjectMockDefaults: Partial> = { error: undefined, references: [], updatedAt: '', + createdAt: '', namespaces: undefined, }; @@ -41,6 +42,7 @@ const createSimpleSavedObjectMock = ( error: savedObject.error, references: savedObject.references, updatedAt: savedObject.updated_at, + createdAt: savedObject.created_at, namespaces: savedObject.namespaces, get: jest.fn(), set: jest.fn(), diff --git a/packages/core/saved-objects/core-saved-objects-common/src/saved_objects.ts b/packages/core/saved-objects/core-saved-objects-common/src/saved_objects.ts index b9bdbafc213c..f98c39871353 100644 --- a/packages/core/saved-objects/core-saved-objects-common/src/saved_objects.ts +++ b/packages/core/saved-objects/core-saved-objects-common/src/saved_objects.ts @@ -74,6 +74,8 @@ export interface SavedObject { type: string; /** An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. */ version?: string; + /** Timestamp of the time this document had been created. */ + created_at?: string; /** Timestamp of the last time this document had been updated. */ updated_at?: string; error?: SavedObjectError; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/kibana_migrator.test.ts.snap b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/kibana_migrator.test.ts.snap index 32c2536ab029..882da8e09695 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/kibana_migrator.test.ts.snap +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/kibana_migrator.test.ts.snap @@ -7,6 +7,7 @@ Object { "amap": "510f1f0adb69830cf8a1c5ce2923ed82", "bmap": "510f1f0adb69830cf8a1c5ce2923ed82", "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", + "created_at": "00da57df13e94e9d98437d13ace4bfe0", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", @@ -35,6 +36,9 @@ Object { "coreMigrationVersion": Object { "type": "keyword", }, + "created_at": Object { + "type": "date", + }, "migrationVersion": Object { "dynamic": "true", "type": "object", diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/__snapshots__/build_active_mappings.test.ts.snap b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/__snapshots__/build_active_mappings.test.ts.snap index 9ee998118bde..4727f8688c61 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/__snapshots__/build_active_mappings.test.ts.snap @@ -7,6 +7,7 @@ Object { "aaa": "625b32086eb1d1203564cf85062dd22e", "bbb": "18c78c995965207ed3f6e7fc5c6e55fe", "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", + "created_at": "00da57df13e94e9d98437d13ace4bfe0", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", @@ -27,6 +28,9 @@ Object { "coreMigrationVersion": Object { "type": "keyword", }, + "created_at": Object { + "type": "date", + }, "migrationVersion": Object { "dynamic": "true", "type": "object", @@ -69,6 +73,7 @@ Object { "_meta": Object { "migrationMappingPropertyHashes": Object { "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", + "created_at": "00da57df13e94e9d98437d13ace4bfe0", "firstType": "635418ab953d81d93f1190b70a8d3f57", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", @@ -86,6 +91,9 @@ Object { "coreMigrationVersion": Object { "type": "keyword", }, + "created_at": Object { + "type": "date", + }, "firstType": Object { "dynamic": "strict", "properties": Object { diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.ts index 1a0a502f655b..f14de6bc72ee 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.ts @@ -139,6 +139,9 @@ function defaultMapping(): IndexMapping { updated_at: { type: 'date', }, + created_at: { + type: 'date', + }, references: { type: 'nested', properties: { diff --git a/packages/core/saved-objects/core-saved-objects-server/src/serialization.ts b/packages/core/saved-objects/core-saved-objects-server/src/serialization.ts index d21e83cc0990..7752d0dd99e9 100644 --- a/packages/core/saved-objects/core-saved-objects-server/src/serialization.ts +++ b/packages/core/saved-objects/core-saved-objects-server/src/serialization.ts @@ -82,6 +82,7 @@ export interface SavedObjectsRawDocSource { namespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; updated_at?: string; + created_at?: string; references?: SavedObjectReference[]; originId?: string; @@ -103,6 +104,7 @@ interface SavedObjectDoc { coreMigrationVersion?: string; version?: string; updated_at?: string; + created_at?: string; originId?: string; } diff --git a/test/api_integration/apis/saved_objects/bulk_create.ts b/test/api_integration/apis/saved_objects/bulk_create.ts index 5867b8125303..43d7d9d8273b 100644 --- a/test/api_integration/apis/saved_objects/bulk_create.ts +++ b/test/api_integration/apis/saved_objects/bulk_create.ts @@ -69,6 +69,7 @@ export default function ({ getService }: FtrProviderContext) { type: 'dashboard', id: 'a01b2f57-fcfd-4864-b735-09e28f0d815e', updated_at: resp.body.saved_objects[1].updated_at, + created_at: resp.body.saved_objects[1].created_at, version: resp.body.saved_objects[1].version, attributes: { title: 'A great new dashboard', diff --git a/test/api_integration/apis/saved_objects/bulk_get.ts b/test/api_integration/apis/saved_objects/bulk_get.ts index e34948296067..4bf9d6c6b334 100644 --- a/test/api_integration/apis/saved_objects/bulk_get.ts +++ b/test/api_integration/apis/saved_objects/bulk_get.ts @@ -54,6 +54,8 @@ export default function ({ getService }: FtrProviderContext) { const mockDate = '2015-01-01T00:00:00.000Z'; resp.body.saved_objects[0].updated_at = mockDate; resp.body.saved_objects[2].updated_at = mockDate; + resp.body.saved_objects[0].created_at = mockDate; + resp.body.saved_objects[2].created_at = mockDate; expect(resp.body).to.eql({ saved_objects: [ @@ -61,6 +63,7 @@ export default function ({ getService }: FtrProviderContext) { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', type: 'visualization', updated_at: '2015-01-01T00:00:00.000Z', + created_at: '2015-01-01T00:00:00.000Z', version: resp.body.saved_objects[0].version, attributes: { title: 'Count of requests', @@ -96,6 +99,7 @@ export default function ({ getService }: FtrProviderContext) { id: '7.0.0-alpha1', type: 'config', updated_at: '2015-01-01T00:00:00.000Z', + created_at: '2015-01-01T00:00:00.000Z', version: resp.body.saved_objects[2].version, attributes: { buildNum: 8467, diff --git a/test/api_integration/apis/saved_objects/create.ts b/test/api_integration/apis/saved_objects/create.ts index 00018e47c9dd..df1e59bc51b2 100644 --- a/test/api_integration/apis/saved_objects/create.ts +++ b/test/api_integration/apis/saved_objects/create.ts @@ -56,6 +56,7 @@ export default function ({ getService }: FtrProviderContext) { migrationVersion: resp.body.migrationVersion, coreMigrationVersion: KIBANA_VERSION, updated_at: resp.body.updated_at, + created_at: resp.body.created_at, version: resp.body.version, attributes: { title: 'My favorite vis', diff --git a/test/api_integration/apis/saved_objects/export.ts b/test/api_integration/apis/saved_objects/export.ts index 6314fbbe675d..4c06573d86e0 100644 --- a/test/api_integration/apis/saved_objects/export.ts +++ b/test/api_integration/apis/saved_objects/export.ts @@ -363,6 +363,7 @@ export default function ({ getService }: FtrProviderContext) { ], type: 'dashboard', updated_at: objects[0].updated_at, + created_at: objects[0].created_at, version: objects[0].version, }); expect(objects[0].migrationVersion).to.be.ok(); @@ -423,6 +424,7 @@ export default function ({ getService }: FtrProviderContext) { ], type: 'dashboard', updated_at: objects[0].updated_at, + created_at: objects[0].created_at, version: objects[0].version, }); expect(objects[0].migrationVersion).to.be.ok(); @@ -488,6 +490,7 @@ export default function ({ getService }: FtrProviderContext) { ], type: 'dashboard', updated_at: objects[0].updated_at, + created_at: objects[0].updated_at, version: objects[0].version, }); expect(objects[0].migrationVersion).to.be.ok(); diff --git a/test/api_integration/apis/saved_objects/get.ts b/test/api_integration/apis/saved_objects/get.ts index 8122308e4493..88c8884c7cdc 100644 --- a/test/api_integration/apis/saved_objects/get.ts +++ b/test/api_integration/apis/saved_objects/get.ts @@ -38,6 +38,7 @@ export default function ({ getService }: FtrProviderContext) { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', type: 'visualization', updated_at: resp.body.updated_at, + created_at: resp.body.created_at, version: resp.body.version, migrationVersion: resp.body.migrationVersion, coreMigrationVersion: KIBANA_VERSION, diff --git a/test/api_integration/apis/saved_objects/resolve.ts b/test/api_integration/apis/saved_objects/resolve.ts index a00a44f98223..8ca1904ef8c8 100644 --- a/test/api_integration/apis/saved_objects/resolve.ts +++ b/test/api_integration/apis/saved_objects/resolve.ts @@ -39,12 +39,14 @@ export default function ({ getService }: FtrProviderContext) { .expect(200) .then((resp) => { resp.body.saved_object.updated_at = '2015-01-01T00:00:00.000Z'; + resp.body.saved_object.created_at = '2015-01-01T00:00:00.000Z'; expect(resp.body).to.eql({ saved_object: { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', type: 'visualization', updated_at: '2015-01-01T00:00:00.000Z', + created_at: '2015-01-01T00:00:00.000Z', version: resp.body.saved_object.version, migrationVersion: resp.body.saved_object.migrationVersion, coreMigrationVersion: KIBANA_VERSION, From 610d7ea4ef01f0be7a24bb39edd6e654763bf460 Mon Sep 17 00:00:00 2001 From: Liam Thompson <32779855+leemthompo@users.noreply.github.com> Date: Thu, 20 Oct 2022 12:58:06 +0200 Subject: [PATCH 34/43] [Enterprise Search] Remove unused `crawlerGettingStarted` doclink (#143690) --- api_docs/kbn_doc_links.devdocs.json | 2 +- packages/kbn-doc-links/src/get_doc_links.ts | 1 - packages/kbn-doc-links/src/types.ts | 1 - .../public/applications/shared/doc_links/doc_links.ts | 3 --- 4 files changed, 1 insertion(+), 6 deletions(-) diff --git a/api_docs/kbn_doc_links.devdocs.json b/api_docs/kbn_doc_links.devdocs.json index e656d78a1fef..a12b3ae2f879 100644 --- a/api_docs/kbn_doc_links.devdocs.json +++ b/api_docs/kbn_doc_links.devdocs.json @@ -300,7 +300,7 @@ "label": "enterpriseSearch", "description": [], "signature": [ - "{ readonly apiKeys: string; readonly bulkApi: string; readonly configuration: string; readonly connectors: string; readonly connectorsMongoDB: string; readonly connectorsMySQL: string; readonly connectorsWorkplaceSearch: string; readonly crawlerGettingStarted: string; readonly crawlerManaging: string; readonly crawlerOverview: string; readonly deployTrainedModels: string; readonly documentLevelSecurity: string; readonly ingestPipelines: string; readonly languageAnalyzers: string; readonly languageClients: string; readonly licenseManagement: string; readonly mailService: string; readonly start: string; readonly troubleshootSetup: string; readonly usersAccess: string; }" + "{ readonly apiKeys: string; readonly bulkApi: string; readonly configuration: string; readonly connectors: string; readonly connectorsMongoDB: string; readonly connectorsMySQL: string; readonly connectorsWorkplaceSearch: string; readonly crawlerManaging: string; readonly crawlerOverview: string; readonly deployTrainedModels: string; readonly documentLevelSecurity: string; readonly ingestPipelines: string; readonly languageAnalyzers: string; readonly languageClients: string; readonly licenseManagement: string; readonly mailService: string; readonly start: string; readonly troubleshootSetup: string; readonly usersAccess: string; }" ], "path": "packages/kbn-doc-links/src/types.ts", "deprecated": false, diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index e848c2943735..8783f21a1af9 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -126,7 +126,6 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { connectorsMongoDB: `${ENTERPRISE_SEARCH_DOCS}connectors-mongodb.html`, connectorsMySQL: `${ENTERPRISE_SEARCH_DOCS}connectors-mysql.html`, connectorsWorkplaceSearch: `${ENTERPRISE_SEARCH_DOCS}connectors.html#connectors-workplace-search`, - crawlerGettingStarted: `${ENTERPRISE_SEARCH_DOCS}crawler-getting-started.html`, crawlerManaging: `${ENTERPRISE_SEARCH_DOCS}crawler-managing.html`, crawlerOverview: `${ENTERPRISE_SEARCH_DOCS}crawler.html`, deployTrainedModels: `${MACHINE_LEARNING_DOCS}ml-nlp-deploy-models.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 9e75b09cd946..443de5027669 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -111,7 +111,6 @@ export interface DocLinks { readonly connectorsMongoDB: string; readonly connectorsMySQL: string; readonly connectorsWorkplaceSearch: string; - readonly crawlerGettingStarted: string; readonly crawlerManaging: string; readonly crawlerOverview: string; readonly deployTrainedModels: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts index 34e781731988..f92b4b0fc36a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts @@ -61,7 +61,6 @@ class DocLinks { public connectorsMongoDB: string; public connectorsMySQL: string; public connectorsWorkplaceSearch: string; - public crawlerGettingStarted: string; public crawlerManaging: string; public crawlerOverview: string; public deployTrainedModels: string; @@ -174,7 +173,6 @@ class DocLinks { this.connectorsMongoDB = ''; this.connectorsMySQL = ''; this.connectorsWorkplaceSearch = ''; - this.crawlerGettingStarted = ''; this.crawlerManaging = ''; this.crawlerOverview = ''; this.deployTrainedModels = ''; @@ -289,7 +287,6 @@ class DocLinks { this.connectorsMongoDB = docLinks.links.enterpriseSearch.connectorsMongoDB; this.connectorsMySQL = docLinks.links.enterpriseSearch.connectorsMySQL; this.connectorsWorkplaceSearch = docLinks.links.enterpriseSearch.connectorsWorkplaceSearch; - this.crawlerGettingStarted = docLinks.links.enterpriseSearch.crawlerGettingStarted; this.crawlerManaging = docLinks.links.enterpriseSearch.crawlerManaging; this.crawlerOverview = docLinks.links.enterpriseSearch.crawlerOverview; this.deployTrainedModels = docLinks.links.enterpriseSearch.deployTrainedModels; From eaf4e5ca2ea8d8f13e1643aafabcac2a5b9215d2 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 20 Oct 2022 14:15:55 +0300 Subject: [PATCH 35/43] [Lens][Visualize] Hides the unused dimension label from the tooltip (#143721) --- .../public/components/heatmap_component.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx index 399d653e0b4b..a497a629553f 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx @@ -619,8 +619,8 @@ export const HeatmapComponent: FC = memo( xScale={xScale} ySortPredicate={yAxisColumn ? getSortPredicate(yAxisColumn) : 'dataIndex'} xSortPredicate={xAxisColumn ? getSortPredicate(xAxisColumn) : 'dataIndex'} - xAxisLabelName={xAxisColumn?.name} - yAxisLabelName={yAxisColumn?.name} + xAxisLabelName={xAxisColumn?.name || ''} + yAxisLabelName={yAxisColumn?.name || ''} xAxisTitle={args.gridConfig.isXAxisTitleVisible ? xAxisTitle : undefined} yAxisTitle={args.gridConfig.isYAxisTitleVisible ? yAxisTitle : undefined} xAxisLabelFormatter={(v) => From c3db6614f697d0a188b62b135175b8b3eaf96ab7 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Thu, 20 Oct 2022 13:57:47 +0200 Subject: [PATCH 36/43] [APM] Add ad-hoc profiling to requests (#143169) Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 2 + package.json | 3 + packages/BUILD.bazel | 2 + packages/kbn-adhoc-profiler/BUILD.bazel | 128 ++++++++++++++++++ packages/kbn-adhoc-profiler/README.md | 5 + packages/kbn-adhoc-profiler/index.ts | 9 ++ .../kbn-adhoc-profiler/inspect_cpu_profile.ts | 28 ++++ packages/kbn-adhoc-profiler/jest.config.js | 13 ++ packages/kbn-adhoc-profiler/kibana.jsonc | 7 + packages/kbn-adhoc-profiler/package.json | 7 + packages/kbn-adhoc-profiler/require_pprof.ts | 14 ++ packages/kbn-adhoc-profiler/tsconfig.json | 18 +++ packages/kbn-adhoc-profiler/types.ts | 13 ++ .../kbn-adhoc-profiler/with_cpu_profile.ts | 32 +++++ .../apm_routes/register_apm_server_routes.ts | 45 ++++-- yarn.lock | 84 +++++++++++- 16 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 packages/kbn-adhoc-profiler/BUILD.bazel create mode 100644 packages/kbn-adhoc-profiler/README.md create mode 100644 packages/kbn-adhoc-profiler/index.ts create mode 100644 packages/kbn-adhoc-profiler/inspect_cpu_profile.ts create mode 100644 packages/kbn-adhoc-profiler/jest.config.js create mode 100644 packages/kbn-adhoc-profiler/kibana.jsonc create mode 100644 packages/kbn-adhoc-profiler/package.json create mode 100644 packages/kbn-adhoc-profiler/require_pprof.ts create mode 100644 packages/kbn-adhoc-profiler/tsconfig.json create mode 100644 packages/kbn-adhoc-profiler/types.ts create mode 100644 packages/kbn-adhoc-profiler/with_cpu_profile.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e7b1ab33b9be..325a39a59b85 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -143,6 +143,7 @@ x-pack/examples/files_example @elastic/kibana-app-services /src/core/types/elasticsearch @elastic/apm-ui /packages/kbn-utility-types/src/dot.ts @dgieselaar /packages/kbn-utility-types/src/dot_test.ts @dgieselaar +/packages/kbn-adhoc-profiler @elastic/apm-ui #CC# /src/plugins/apm_oss/ @elastic/apm-ui #CC# /x-pack/plugins/observability/ @elastic/apm-ui @@ -855,6 +856,7 @@ packages/home/sample_data_card @elastic/shared-ux packages/home/sample_data_tab @elastic/shared-ux packages/home/sample_data_types @elastic/shared-ux packages/kbn-ace @elastic/platform-deployment-management +packages/kbn-adhoc-profiler @elastic/apm-ui packages/kbn-alerts @elastic/security-solution packages/kbn-ambient-storybook-types @elastic/kibana-operations packages/kbn-ambient-ui-types @elastic/kibana-operations diff --git a/package.json b/package.json index 452cc5c7dca6..fbab0181a7f8 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,7 @@ "@hapi/inert": "^6.0.4", "@hapi/wreck": "^17.1.0", "@kbn/ace": "link:bazel-bin/packages/kbn-ace", + "@kbn/adhoc-profiler": "link:bazel-bin/packages/kbn-adhoc-profiler", "@kbn/aiops-components": "link:bazel-bin/x-pack/packages/ml/aiops_components", "@kbn/aiops-utils": "link:bazel-bin/x-pack/packages/ml/aiops_utils", "@kbn/alerts": "link:bazel-bin/packages/kbn-alerts", @@ -574,6 +575,7 @@ "peggy": "^1.2.0", "pluralize": "3.1.0", "polished": "^3.7.2", + "pprof": "^3.2.0", "pretty-ms": "6.0.0", "prop-types": "^15.8.1", "proxy-from-env": "1.0.0", @@ -858,6 +860,7 @@ "@types/json5": "^0.0.30", "@types/jsonwebtoken": "^8.5.6", "@types/kbn__ace": "link:bazel-bin/packages/kbn-ace/npm_module_types", + "@types/kbn__adhoc-profiler": "link:bazel-bin/packages/kbn-adhoc-profiler/npm_module_types", "@types/kbn__aiops-components": "link:bazel-bin/x-pack/packages/ml/aiops_components/npm_module_types", "@types/kbn__aiops-utils": "link:bazel-bin/x-pack/packages/ml/aiops_utils/npm_module_types", "@types/kbn__alerts": "link:bazel-bin/packages/kbn-alerts/npm_module_types", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index b68c27b27f3d..95c72f1cd9b9 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -181,6 +181,7 @@ filegroup( "//packages/home/sample_data_tab:build", "//packages/home/sample_data_types:build", "//packages/kbn-ace:build", + "//packages/kbn-adhoc-profiler:build", "//packages/kbn-alerts:build", "//packages/kbn-ambient-storybook-types:build", "//packages/kbn-ambient-ui-types:build", @@ -529,6 +530,7 @@ filegroup( "//packages/home/sample_data_card:build_types", "//packages/home/sample_data_tab:build_types", "//packages/kbn-ace:build_types", + "//packages/kbn-adhoc-profiler:build_types", "//packages/kbn-alerts:build_types", "//packages/kbn-analytics:build_types", "//packages/kbn-apm-config-loader:build_types", diff --git a/packages/kbn-adhoc-profiler/BUILD.bazel b/packages/kbn-adhoc-profiler/BUILD.bazel new file mode 100644 index 000000000000..d3ecbb56e657 --- /dev/null +++ b/packages/kbn-adhoc-profiler/BUILD.bazel @@ -0,0 +1,128 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "kbn-adhoc-profiler" +PKG_REQUIRE_NAME = "@kbn/adhoc-profiler" + +SOURCE_FILES = glob( + [ + "**/*.ts", + ], + exclude = [ + "**/*.config.js", + "**/*.mock.*", + "**/*.test.*", + "**/*.stories.*", + "**/__snapshots__/**", + "**/integration_tests/**", + "**/mocks/**", + "**/scripts/**", + "**/storybook/**", + "**/test_fixtures/**", + "**/test_helpers/**", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "@npm//pprof", + "@npm//execa" +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "@npm//@types/node", + "@npm//@types/jest", + "@npm//pprof", + "@npm//execa" +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + declaration_map = True, + emit_declaration_only = True, + out_dir = "target_types", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-adhoc-profiler/README.md b/packages/kbn-adhoc-profiler/README.md new file mode 100644 index 000000000000..688102937385 --- /dev/null +++ b/packages/kbn-adhoc-profiler/README.md @@ -0,0 +1,5 @@ +# @kbn/adhoc-profiler + +This package offers tools for ad hoc profiling. Currently it only exports one method: `inspectCpuProfile`, which will start a CPU profile before executing the callback it is given, and opens the collected profile in a web browser. It assumes that you have `go` and [`pprof`](https://github.com/google/pprof) installed. + +Profiles are stored in the user's temporary directory (returned from `os.tmpdir()`). diff --git a/packages/kbn-adhoc-profiler/index.ts b/packages/kbn-adhoc-profiler/index.ts new file mode 100644 index 000000000000..5aa7c8e9526e --- /dev/null +++ b/packages/kbn-adhoc-profiler/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { inspectCpuProfile } from './inspect_cpu_profile'; diff --git a/packages/kbn-adhoc-profiler/inspect_cpu_profile.ts b/packages/kbn-adhoc-profiler/inspect_cpu_profile.ts new file mode 100644 index 000000000000..e48c6989d0d0 --- /dev/null +++ b/packages/kbn-adhoc-profiler/inspect_cpu_profile.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import Fs from 'fs'; +import Os from 'os'; +import Path from 'path'; + +import execa from 'execa'; + +import { pprof } from './require_pprof'; +import { withCpuProfile } from './with_cpu_profile'; + +export function inspectCpuProfile(callback: () => T): T; + +export function inspectCpuProfile(callback: () => any) { + return withCpuProfile(callback, (profile) => { + pprof.encode(profile).then((buffer) => { + const filename = Path.join(Os.tmpdir(), Date.now() + '.pb.gz'); + Fs.writeFile(filename, buffer, (err) => { + execa('pprof', ['-web', filename]); + }); + }); + }); +} diff --git a/packages/kbn-adhoc-profiler/jest.config.js b/packages/kbn-adhoc-profiler/jest.config.js new file mode 100644 index 000000000000..ec035df5193f --- /dev/null +++ b/packages/kbn-adhoc-profiler/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-adhoc-profiler'], +}; diff --git a/packages/kbn-adhoc-profiler/kibana.jsonc b/packages/kbn-adhoc-profiler/kibana.jsonc new file mode 100644 index 000000000000..bae020a4b1e8 --- /dev/null +++ b/packages/kbn-adhoc-profiler/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/adhoc-profiler", + "owner": "@elastic/apm-ui", + "runtimeDeps": [], + "typeDeps": [], +} diff --git a/packages/kbn-adhoc-profiler/package.json b/packages/kbn-adhoc-profiler/package.json new file mode 100644 index 000000000000..aeee4a9d1d37 --- /dev/null +++ b/packages/kbn-adhoc-profiler/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/adhoc-profiler", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/kbn-adhoc-profiler/require_pprof.ts b/packages/kbn-adhoc-profiler/require_pprof.ts new file mode 100644 index 000000000000..beba96c2a479 --- /dev/null +++ b/packages/kbn-adhoc-profiler/require_pprof.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PProf } from './types'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const pprof = require('pprof') as PProf; + +export { pprof }; diff --git a/packages/kbn-adhoc-profiler/tsconfig.json b/packages/kbn-adhoc-profiler/tsconfig.json new file mode 100644 index 000000000000..bf5ec76a5e1f --- /dev/null +++ b/packages/kbn-adhoc-profiler/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "stripInternal": false, + "types": [ + "jest", + "node", + "long" + ] + }, + "include": [ + "**/*.ts", + ] +} diff --git a/packages/kbn-adhoc-profiler/types.ts b/packages/kbn-adhoc-profiler/types.ts new file mode 100644 index 000000000000..76ef307b75de --- /dev/null +++ b/packages/kbn-adhoc-profiler/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type pprofRuntime from 'pprof'; + +type PProf = typeof pprofRuntime; +type Profile = ReturnType>; + +export type { PProf, Profile }; diff --git a/packages/kbn-adhoc-profiler/with_cpu_profile.ts b/packages/kbn-adhoc-profiler/with_cpu_profile.ts new file mode 100644 index 000000000000..25833f8cb480 --- /dev/null +++ b/packages/kbn-adhoc-profiler/with_cpu_profile.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { isPromise } from 'util/types'; +import { pprof } from './require_pprof'; +import { Profile } from './types'; + +export function withCpuProfile(callback: () => T, onProfileDone: (profile: Profile) => void): T; + +export function withCpuProfile(callback: () => any, onProfileDone: (profile: Profile) => void) { + const stop = pprof.time.start(); + + const result = callback(); + + function collectProfile() { + const profile = stop(); + onProfileDone(profile); + } + + if (isPromise(result)) { + result.finally(() => { + collectProfile(); + }); + } else { + collectProfile(); + } + return result; +} diff --git a/x-pack/plugins/apm/server/routes/apm_routes/register_apm_server_routes.ts b/x-pack/plugins/apm/server/routes/apm_routes/register_apm_server_routes.ts index 6de9c99cdbcd..f0e1f67ec2be 100644 --- a/x-pack/plugins/apm/server/routes/apm_routes/register_apm_server_routes.ts +++ b/x-pack/plugins/apm/server/routes/apm_routes/register_apm_server_routes.ts @@ -5,23 +5,25 @@ * 2.0. */ +import { errors } from '@elastic/elasticsearch'; import Boom from '@hapi/boom'; -import * as t from 'io-ts'; +import { RequestHandler } from '@kbn/core-http-server'; import { KibanaRequest, RouteRegistrar } from '@kbn/core/server'; -import { errors } from '@elastic/elasticsearch'; -import agent from 'elastic-apm-node'; -import { ServerRouteRepository } from '@kbn/server-route-repository'; -import { merge } from 'lodash'; +import { jsonRt, mergeRt } from '@kbn/io-ts-utils'; +import { InspectResponse } from '@kbn/observability-plugin/typings/common'; import { decodeRequestParams, parseEndpoint, routeValidationObject, + ServerRouteRepository, } from '@kbn/server-route-repository'; -import { jsonRt, mergeRt } from '@kbn/io-ts-utils'; -import { InspectResponse } from '@kbn/observability-plugin/typings/common'; +import agent from 'elastic-apm-node'; +import * as t from 'io-ts'; +import { merge } from 'lodash'; +import { inspectCpuProfile } from '@kbn/adhoc-profiler'; import { pickKeys } from '../../../common/utils/pick_keys'; -import { APMRouteHandlerResources, TelemetryUsageCounter } from '../typings'; import type { ApmPluginRequestHandlerContext } from '../typings'; +import { APMRouteHandlerResources, TelemetryUsageCounter } from '../typings'; const inspectRt = t.exact( t.partial({ @@ -64,6 +66,29 @@ export function registerRoutes({ const router = core.setup.http.createRouter(); + function wrapRouteHandlerInProfiler( + handler: RequestHandler< + unknown, + unknown, + unknown, + ApmPluginRequestHandlerContext + > + ): RequestHandler< + unknown, + { _profile?: 'inspect' }, + unknown, + ApmPluginRequestHandlerContext + > { + return (context, request, response) => { + const { _profile } = request.query; + if (_profile === 'inspect') { + delete request.query._profile; + return inspectCpuProfile(() => handler(context, request, response)); + } + return handler(context, request, response); + }; + } + routes.forEach((route) => { const { params, endpoint, options, handler } = route; @@ -80,7 +105,7 @@ export function registerRoutes({ options, validate: routeValidationObject, }, - async (context, request, response) => { + wrapRouteHandlerInProfiler(async (context, request, response) => { if (agent.isStarted()) { agent.addLabels({ plugin: 'apm', @@ -186,7 +211,7 @@ export function registerRoutes({ // cleanup inspectableEsQueriesMap.delete(request); } - } + }) ); }); } diff --git a/yarn.lock b/yarn.lock index 00d479810e66..39da46195d79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2605,6 +2605,10 @@ version "0.0.0" uid "" +"@kbn/adhoc-profiler@link:bazel-bin/packages/kbn-adhoc-profiler": + version "0.0.0" + uid "" + "@kbn/aiops-components@link:bazel-bin/x-pack/packages/ml/aiops_components": version "0.0.0" uid "" @@ -4166,6 +4170,21 @@ resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-2.0.1.tgz#c15367178d8bfe4765e6b47b542fe821ce259c7b" integrity sha512-HP6XvfNIzfoMVfyGjBckjiAOQK9WfX0ywdLubuPMPv+Vqf5fj0uCbgBQYpiqcWZT6cbyyRnTSXDheT1ugvF6UQ== +"@mapbox/node-pre-gyp@^1.0.0": + version "1.0.10" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz#8e6735ccebbb1581e5a7e652244cadc8a844d03c" + integrity sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + "@mapbox/point-geometry@0.1.0", "@mapbox/point-geometry@^0.1.0", "@mapbox/point-geometry@~0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz#8a83f9335c7860effa2eeeca254332aa0aeed8f2" @@ -6797,6 +6816,10 @@ version "0.0.0" uid "" +"@types/kbn__adhoc-profiler@link:bazel-bin/packages/kbn-adhoc-profiler/npm_module_types": + version "0.0.0" + uid "" + "@types/kbn__aiops-components@link:bazel-bin/x-pack/packages/ml/aiops_components/npm_module_types": version "0.0.0" uid "" @@ -10811,6 +10834,13 @@ binary-search@^1.3.3: resolved "https://registry.yarnpkg.com/binary-search/-/binary-search-1.3.6.tgz#e32426016a0c5092f0f3598836a1c7da3560565c" integrity sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA== +bindings@^1.2.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + bitmap-sdf@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/bitmap-sdf/-/bitmap-sdf-1.0.3.tgz#c99913e5729357a6fd350de34158180c013880b2" @@ -13479,6 +13509,11 @@ delaunator@5: dependencies: robust-predicates "^3.0.0" +delay@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/delay/-/delay-5.0.0.tgz#137045ef1b96e5071060dd5be60bf9334436bd1d" + integrity sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw== + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -15410,6 +15445,11 @@ file-system-cache@^1.0.5: fs-extra "^0.30.0" ramda "^0.21.0" +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + filelist@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" @@ -15508,6 +15548,11 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" +findit2@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/findit2/-/findit2-2.2.3.tgz#58a466697df8a6205cdfdbf395536b8bd777a5f6" + integrity sha512-lg/Moejf4qXovVutL0Lz4IsaPoNYMuxt4PA0nGqFxnJ1CTTGGlEO2wKgoDpwknhvZ8k4Q2F+eesgkLbG2Mxfog== + flat-cache@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" @@ -20832,6 +20877,11 @@ nan@^2.13.2, nan@^2.14.2, nan@^2.15.0: resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== +nan@^2.14.0: + version "2.17.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" + integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== + nano-css@^5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/nano-css/-/nano-css-5.2.1.tgz#73b8470fa40b028a134d3393ae36bbb34b9fa332" @@ -21805,7 +21855,7 @@ p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.3.0: dependencies: p-try "^2.0.0" -p-limit@^3.0.1, p-limit@^3.0.2: +p-limit@^3.0.0, p-limit@^3.0.1, p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== @@ -22237,6 +22287,11 @@ pify@^4.0.1: resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== +pify@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-5.0.0.tgz#1f5eca3f5e87ebec28cc6d54a0e4aaf00acc127f" + integrity sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA== + pinkie-promise@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" @@ -22779,6 +22834,22 @@ potpack@^1.0.2: resolved "https://registry.yarnpkg.com/potpack/-/potpack-1.0.2.tgz#23b99e64eb74f5741ffe7656b5b5c4ddce8dfc14" integrity sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ== +pprof@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/pprof/-/pprof-3.2.0.tgz#5a60638dc51a61128a3d57c74514e8fd99e93722" + integrity sha512-yhORhVWefg94HZgjVa6CDtYSNZJnJzZ82d4pkmrZJxf1/Y29Me/uHYLEVo6KawKKFhQywl5cGbkdnVx9bZoMew== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.0" + bindings "^1.2.1" + delay "^5.0.0" + findit2 "^2.2.3" + nan "^2.14.0" + p-limit "^3.0.0" + pify "^5.0.0" + protobufjs "~6.11.0" + source-map "^0.7.3" + split "^1.0.1" + preact-render-to-string@^5.1.19: version "5.1.19" resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-5.1.19.tgz#ffae7c3bd1680be5ecf5991d41fe3023b3051e0e" @@ -23059,7 +23130,7 @@ protobufjs@6.8.8: "@types/node" "^10.1.0" long "^4.0.0" -protobufjs@^6.11.3: +protobufjs@^6.11.3, protobufjs@~6.11.0: version "6.11.3" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.3.tgz#637a527205a35caa4f3e2a9a4a13ddffe0e7af74" integrity sha512-xL96WDdCZYdU7Slin569tFX712BxsxslWwAfAhCYjQKGTq7dAU91Lomy6nLLhh/dyGhk/YH4TwTSRxTzhuHyZg== @@ -25886,6 +25957,13 @@ split-string@^3.0.1, split-string@^3.0.2: dependencies: extend-shallow "^3.0.0" +split@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9" + integrity sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg== + dependencies: + through "2" + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -26879,7 +26957,7 @@ through2@~0.4.1: readable-stream "~1.0.17" xtend "~2.1.1" -"through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6, through@^2.3.8, through@~2.3.4: +through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6, through@^2.3.8, through@~2.3.4: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= From 323244ef2b9e024f5321afad639fb3ce088ff656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Thu, 20 Oct 2022 14:18:43 +0200 Subject: [PATCH 37/43] [Osquery] Add tagging support for osquery assets (#143417) --- .../services/epm/kibana/assets/install.ts | 2 +- .../epm/kibana/assets/tag_assets.test.ts | 10 ++++++- .../services/epm/kibana/assets/tag_assets.ts | 3 +- .../osquery/server/utils/register_features.ts | 8 ++--- .../saved_objects_tagging/common/constants.ts | 11 ++++++- .../server/usage/schema.ts | 3 ++ .../schema/xpack_plugins.json | 30 +++++++++++++++++++ .../apis/_get_assignable_types.ts | 11 ++++++- 8 files changed, 69 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index 62b608376986..92ea60d29004 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -51,7 +51,7 @@ export type ArchiveAsset = Pick< // KibanaSavedObjectTypes are used to ensure saved objects being created for a given // KibanaAssetType have the correct type -const KibanaSavedObjectTypeMapping: Record = { +export const KibanaSavedObjectTypeMapping: Record = { [KibanaAssetType.dashboard]: KibanaSavedObjectType.dashboard, [KibanaAssetType.indexPattern]: KibanaSavedObjectType.indexPattern, [KibanaAssetType.map]: KibanaSavedObjectType.map, diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.test.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.test.ts index 33f674d226e8..3c946217d36b 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.test.ts @@ -94,6 +94,8 @@ describe('tagKibanaAssets', () => { search: [{ id: 's1', type: 'search' }], config: [{ id: 'c1', type: 'config' }], visualization: [{ id: 'v1', type: 'visualization' }], + osquery_pack_asset: [{ id: 'osquery-pack-asset1', type: 'osquery-pack-asset' }], + osquery_saved_query: [{ id: 'osquery_saved_query1', type: 'osquery_saved_query' }], } as any; await tagKibanaAssets({ @@ -106,7 +108,13 @@ describe('tagKibanaAssets', () => { expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({ tags: ['managed', 'system'], - assign: [...kibanaAssets.dashboard, ...kibanaAssets.search, ...kibanaAssets.visualization], + assign: [ + ...kibanaAssets.dashboard, + ...kibanaAssets.search, + ...kibanaAssets.visualization, + ...kibanaAssets.osquery_pack_asset, + ...kibanaAssets.osquery_saved_query, + ], unassign: [], refresh: false, }); diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.ts index 53a6df568a8c..842932d71359 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.ts @@ -11,6 +11,7 @@ import type { IAssignmentService, ITagsClient } from '@kbn/saved-objects-tagging import type { KibanaAssetType } from '../../../../../common'; import type { ArchiveAsset } from './install'; +import { KibanaSavedObjectTypeMapping } from './install'; const TAG_COLOR = '#FFFFFF'; const MANAGED_TAG_NAME = 'Managed'; @@ -30,7 +31,7 @@ export async function tagKibanaAssets({ pkgName: string; }) { const taggableAssets = Object.entries(kibanaAssets).flatMap(([assetType, assets]) => { - if (!taggableTypes.includes(assetType as KibanaAssetType)) { + if (!taggableTypes.includes(KibanaSavedObjectTypeMapping[assetType as KibanaAssetType])) { return []; } diff --git a/x-pack/plugins/osquery/server/utils/register_features.ts b/x-pack/plugins/osquery/server/utils/register_features.ts index 4925b1ea14ad..5af2335489df 100644 --- a/x-pack/plugins/osquery/server/utils/register_features.ts +++ b/x-pack/plugins/osquery/server/utils/register_features.ts @@ -115,7 +115,7 @@ export const registerFeatures = (features: SetupPlugins['features']) => { name: 'All', savedObject: { all: [savedQuerySavedObjectType], - read: [], + read: ['tag'], }, ui: ['writeSavedQueries', 'readSavedQueries'], }, @@ -126,7 +126,7 @@ export const registerFeatures = (features: SetupPlugins['features']) => { name: 'Read', savedObject: { all: [], - read: [savedQuerySavedObjectType], + read: [savedQuerySavedObjectType, 'tag'], }, ui: ['readSavedQueries'], }, @@ -149,7 +149,7 @@ export const registerFeatures = (features: SetupPlugins['features']) => { name: 'All', savedObject: { all: [packSavedObjectType, packAssetSavedObjectType], - read: [], + read: ['tag'], }, ui: ['writePacks', 'readPacks'], }, @@ -160,7 +160,7 @@ export const registerFeatures = (features: SetupPlugins['features']) => { name: 'Read', savedObject: { all: [], - read: [packSavedObjectType], + read: [packSavedObjectType, 'tag'], }, ui: ['readPacks'], }, diff --git a/x-pack/plugins/saved_objects_tagging/common/constants.ts b/x-pack/plugins/saved_objects_tagging/common/constants.ts index acd9800e03ce..c65e52e2ff04 100644 --- a/x-pack/plugins/saved_objects_tagging/common/constants.ts +++ b/x-pack/plugins/saved_objects_tagging/common/constants.ts @@ -20,4 +20,13 @@ export const tagManagementSectionId = 'tags'; /** * The list of saved object types that are currently supporting tagging. */ -export const taggableTypes = ['dashboard', 'visualization', 'map', 'lens', 'search']; +export const taggableTypes = [ + 'dashboard', + 'visualization', + 'map', + 'lens', + 'search', + 'osquery-pack', + 'osquery-pack-asset', + 'osquery-saved-query', +]; diff --git a/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts b/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts index 5f0c23a54f9e..9f516c953cab 100644 --- a/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts +++ b/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts @@ -23,5 +23,8 @@ export const tagUsageCollectorSchema: MakeSchemaFrom = { visualization: perTypeSchema, map: perTypeSchema, search: perTypeSchema, + 'osquery-pack': perTypeSchema, + 'osquery-pack-asset': perTypeSchema, + 'osquery-saved-query': perTypeSchema, }, }; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 7b850a44528f..5eb2c8d5a586 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -9355,6 +9355,36 @@ "type": "integer" } } + }, + "osquery-pack": { + "properties": { + "usedTags": { + "type": "integer" + }, + "taggedObjects": { + "type": "integer" + } + } + }, + "osquery-pack-asset": { + "properties": { + "usedTags": { + "type": "integer" + }, + "taggedObjects": { + "type": "integer" + } + } + }, + "osquery-saved-query": { + "properties": { + "usedTags": { + "type": "integer" + }, + "taggedObjects": { + "type": "integer" + } + } } } } diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_get_assignable_types.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_get_assignable_types.ts index 673ae6f73fac..479fbe681d89 100644 --- a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_get_assignable_types.ts +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_get_assignable_types.ts @@ -32,7 +32,16 @@ export default function (ftrContext: FtrProviderContext) { }); const assignablePerUser = { - [USERS.SUPERUSER.username]: ['dashboard', 'visualization', 'map', 'lens', 'search'], + [USERS.SUPERUSER.username]: [ + 'dashboard', + 'visualization', + 'map', + 'lens', + 'search', + 'osquery-pack', + 'osquery-pack-asset', + 'osquery-saved-query', + ], [USERS.DEFAULT_SPACE_SO_TAGGING_READ_USER.username]: [], [USERS.DEFAULT_SPACE_READ_USER.username]: [], [USERS.DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER.username]: [], From c1175f971a8eedd844514744b12bb9acfd333671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Thu, 20 Oct 2022 08:21:39 -0400 Subject: [PATCH 38/43] Remove #CC from codeowners (#143736) --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 325a39a59b85..50bd3144b1cc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -349,12 +349,12 @@ x-pack/examples/files_example @elastic/kibana-app-services /x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/ @elastic/response-ops /docs/user/alerting/ @elastic/response-ops @elastic/mlr-docs /docs/management/connectors/ @elastic/response-ops @elastic/mlr-docs -#CC# /x-pack/plugins/stack_alerts/ @elastic/response-ops +/x-pack/plugins/stack_alerts/ @elastic/response-ops /x-pack/plugins/cases/ @elastic/response-ops /x-pack/test/cases_api_integration/ @elastic/response-ops /x-pack/test/functional/services/cases/ @elastic/response-ops /x-pack/test/functional_with_es_ssl/apps/cases/ @elastic/response-ops -/x-pack/test/api_integration/apis/cases @elastic/response-ops +/x-pack/test/api_integration/apis/cases/ @elastic/response-ops # Enterprise Search /x-pack/plugins/enterprise_search @elastic/enterprise-search-frontend From 719d680c9df642c6526d0b3b176e670692019444 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 20 Oct 2022 14:25:54 +0200 Subject: [PATCH 39/43] [Lens] Allow value labels on histogram and stacked charts (#143635) * allow value labels on histogram and stacked charts * fix tests * fix test Co-authored-by: Stratoula Kalafateli --- .../common/expression_functions/validate.ts | 26 ++++--------- .../common/expression_functions/xy_vis_fn.ts | 5 +-- .../__snapshots__/xy_chart.test.tsx.snap | 6 +-- .../public/components/xy_chart.tsx | 7 +--- .../value_labels_settings.tsx | 23 ++++++++++- .../visual_options_popover/index.tsx | 10 ++--- .../visual_options_popover.test.tsx | 39 +------------------ .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../apps/lens/group1/smokescreen.ts | 2 +- 11 files changed, 38 insertions(+), 83 deletions(-) diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts index 6c88d0ff6a62..efdbe7e2d008 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts @@ -77,14 +77,10 @@ export const errors = { i18n.translate('expressionXY.reusable.function.xyVis.errors.notUsedFillOpacityError', { defaultMessage: '`fillOpacity` argument is applicable only for area charts.', }), - valueLabelsForNotBarsOrHistogramBarsChartsError: () => - i18n.translate( - 'expressionXY.reusable.function.xyVis.errors.valueLabelsForNotBarsOrHistogramBarsChartsError', - { - defaultMessage: - '`valueLabels` argument is applicable only for bar charts, which are not histograms.', - } - ), + valueLabelsForNotBarsChartsError: () => + i18n.translate('expressionXY.reusable.function.xyVis.errors.valueLabelsForNotBarChartsError', { + defaultMessage: '`valueLabels` argument is applicable only for bar charts.', + }), dataBoundsForNotLineChartError: () => i18n.translate('expressionXY.reusable.function.xyVis.errors.dataBoundsForNotLineChartError', { defaultMessage: 'Only line charts can be fit to the data bounds', @@ -127,10 +123,6 @@ export const hasBarLayer = (layers: Array) => layers.some(({ seriesType }) => seriesType === SeriesTypes.AREA); -export const hasHistogramBarLayer = ( - layers: Array -) => layers.some(({ seriesType, isHistogram }) => seriesType === SeriesTypes.BAR && isHistogram); - export const isValidExtentWithCustomMode = (extent: AxisExtentConfigResult) => { const isValidLowerBound = extent.lowerBound === undefined || (extent.lowerBound !== undefined && extent.lowerBound <= 0); @@ -212,13 +204,9 @@ export const validateFillOpacity = (fillOpacity: number | undefined, hasArea: bo } }; -export const validateValueLabels = ( - valueLabels: ValueLabelMode, - hasBar: boolean, - hasNotHistogramBars: boolean -) => { - if ((!hasBar || !hasNotHistogramBars) && valueLabels !== ValueLabelModes.HIDE) { - throw new Error(errors.valueLabelsForNotBarsOrHistogramBarsChartsError()); +export const validateValueLabels = (valueLabels: ValueLabelMode, hasBar: boolean) => { + if (!hasBar && valueLabels !== ValueLabelModes.HIDE) { + throw new Error(errors.valueLabelsForNotBarsChartsError()); } }; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index f7577efc45b8..ac867401dbe2 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -15,7 +15,6 @@ import { DataLayerConfigResult, XYLayerConfig, XyVisFn, XYArgs } from '../types' import { hasAreaLayer, hasBarLayer, - hasHistogramBarLayer, validateExtents, validateFillOpacity, validateMarkSizeRatioLimits, @@ -112,9 +111,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateAddTimeMarker(dataLayers, args.addTimeMarker); validateMinTimeBarInterval(dataLayers, hasBar, args.minTimeBarInterval); - const hasNotHistogramBars = !hasHistogramBarLayer(dataLayers); - - validateValueLabels(args.valueLabels, hasBar, hasNotHistogramBars); + validateValueLabels(args.valueLabels, hasBar); validateMarkSizeRatioWithAccessor(args.markSizeRatio, dataLayers[0].markSizeAccessor); validateMarkSizeRatioLimits(args.markSizeRatio); validateLineWidthForChartType(lineWidth, args.seriesType); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index a756ad587f39..515657121865 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -5119,7 +5119,7 @@ exports[`XYChart component it renders stacked area 1`] = ` "getAll": [Function], } } - shouldShowValueLabels={false} + shouldShowValueLabels={true} syncColors={false} timeZone="UTC" titles={ @@ -6101,7 +6101,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` "getAll": [Function], } } - shouldShowValueLabels={false} + shouldShowValueLabels={true} syncColors={false} timeZone="UTC" titles={ @@ -7083,7 +7083,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` "getAll": [Function], } } - shouldShowValueLabels={false} + shouldShowValueLabels={true} syncColors={false} timeZone="UTC" titles={ diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index 240c4e182d43..c03cb9a664f1 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -523,12 +523,7 @@ export function XYChart({ }; }; - const shouldShowValueLabels = uiState - ? valueLabels !== ValueLabelModes.HIDE - : // No stacked bar charts - dataLayers.every((layer) => !layer.isStacked) && - // No histogram charts - !isHistogramViz; + const shouldShowValueLabels = !uiState || valueLabels !== ValueLabelModes.HIDE; const valueLabelsStyling = shouldShowValueLabels && diff --git a/x-pack/plugins/lens/public/shared_components/value_labels_settings.tsx b/x-pack/plugins/lens/public/shared_components/value_labels_settings.tsx index f5378a2e3ba0..929478a67beb 100644 --- a/x-pack/plugins/lens/public/shared_components/value_labels_settings.tsx +++ b/x-pack/plugins/lens/public/shared_components/value_labels_settings.tsx @@ -7,7 +7,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonGroup, EuiFormRow } from '@elastic/eui'; +import { EuiButtonGroup, EuiFormRow, EuiIconTip } from '@elastic/eui'; import { ValueLabelConfig } from '../../common/types'; const valueLabelsOptions: Array<{ @@ -54,7 +54,26 @@ export const ValueLabelsSettings: FC = ({ const isSelected = valueLabelsOptions.find(({ value }) => value === valueLabels)?.id || 'value_labels_hide'; return ( - {label}}> + + {label}{' '} + + + } + > = ({ ); const hasNonBarSeries = dataLayers.some(({ seriesType }) => - ['area_stacked', 'area', 'line'].includes(seriesType) - ); - - const hasBarNotStacked = dataLayers.some(({ seriesType }) => - ['bar', 'bar_horizontal'].includes(seriesType) + ['area_stacked', 'area', 'line', 'area_percentage_stacked'].includes(seriesType) ); const hasAreaSeries = dataLayers.some(({ seriesType }) => @@ -68,8 +64,8 @@ export const VisualOptionsPopover: React.FC = ({ const isHistogramSeries = Boolean(hasHistogramSeries(dataLayers, datasourceLayers)); - const isValueLabelsEnabled = !hasNonBarSeries && hasBarNotStacked && !isHistogramSeries; - const isFittingEnabled = hasNonBarSeries; + const isValueLabelsEnabled = !hasNonBarSeries; + const isFittingEnabled = hasNonBarSeries && !isAreaPercentage; const isCurveTypeEnabled = hasNonBarSeries || isAreaPercentage; const valueLabelsDisabledReason = getValueLabelDisableReason({ diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx index 0e29a5fa6634..a6e6d54893d6 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { shallowWithIntl as shallow } from '@kbn/test-jest-helpers'; import { Position } from '@elastic/charts'; -import type { FramePublicAPI, DatasourcePublicAPI } from '../../../../types'; +import type { FramePublicAPI } from '../../../../types'; import { createMockDatasource, createMockFramePublicAPI } from '../../../../mocks'; import { State, XYLayerConfig } from '../../types'; import { VisualOptionsPopover } from '.'; @@ -44,21 +44,6 @@ describe('Visual options popover', () => { first: createMockDatasource('test').publicAPIMock, }; }); - it('should disable the visual options for stacked bar charts', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); - }); it('should disable the values and fitting for percentage area charts', () => { const state = testState(); @@ -109,28 +94,6 @@ describe('Visual options popover', () => { expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(false); }); - it('should disabled the popover if there is histogram series', () => { - // make it detect an histogram series - const datasourceLayers = frame.datasourceLayers as Record; - datasourceLayers.first.getOperationForColumnId = jest.fn().mockReturnValueOnce({ - isBucketed: true, - scale: 'interval', - }); - const state = testState(); - const component = shallow( - - ); - - expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); - }); - it('should hide the fitting option for bar series', () => { const state = testState(); const component = shallow( diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 844e40db8b25..88e4610c3e74 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -2729,7 +2729,6 @@ "expressionXY.reusable.function.xyVis.errors.pointsRadiusForNonLineOrAreaChartError": "\"pointsRadius\" peut être appliqué uniquement aux graphiques linéaires ou aux graphiques en aires", "expressionXY.reusable.function.xyVis.errors.showPointsForNonLineOrAreaChartError": "\"showPoints\" peut être appliqué uniquement aux graphiques linéaires ou aux graphiques en aires", "expressionXY.reusable.function.xyVis.errors.timeMarkerForNotTimeChartsError": "Seuls les graphiques temporels peuvent avoir un repère de temps actuel", - "expressionXY.reusable.function.xyVis.errors.valueLabelsForNotBarsOrHistogramBarsChartsError": "L'argument \"valueLabels\" s'applique uniquement aux graphiques à barres qui ne sont pas des histogrammes.", "expressionXY.xAxisConfigFn.help": "Configurer la config de l'axe x du graphique xy", "expressionXY.xyChart.emptyXLabel": "(vide)", "expressionXY.xyChart.iconSelect.alertIconLabel": "Alerte", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 23b7ddf6ff13..9c20bf06e9f4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2725,7 +2725,6 @@ "expressionXY.reusable.function.xyVis.errors.pointsRadiusForNonLineOrAreaChartError": "pointsRadiusは折れ線グラフまたは面グラフでのみ適用できます。", "expressionXY.reusable.function.xyVis.errors.showPointsForNonLineOrAreaChartError": "showPointsは折れ線グラフまたは面グラフでのみ適用できます。", "expressionXY.reusable.function.xyVis.errors.timeMarkerForNotTimeChartsError": "現在時刻マーカーを設定できるのは、時系列グラフのみです", - "expressionXY.reusable.function.xyVis.errors.valueLabelsForNotBarsOrHistogramBarsChartsError": "valueLabels引数は棒グラフでのみ適用できます。これはヒストグラフではありません。", "expressionXY.xAxisConfigFn.help": "xyグラフのx軸設定を構成", "expressionXY.xyChart.emptyXLabel": "(空)", "expressionXY.xyChart.iconSelect.alertIconLabel": "アラート", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index dd483770f0df..70faac2c6693 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2729,7 +2729,6 @@ "expressionXY.reusable.function.xyVis.errors.pointsRadiusForNonLineOrAreaChartError": "`pointsRadius` 仅适用于折线图或面积图", "expressionXY.reusable.function.xyVis.errors.showPointsForNonLineOrAreaChartError": "`showPoints` 仅适用于折线图或面积图", "expressionXY.reusable.function.xyVis.errors.timeMarkerForNotTimeChartsError": "仅时间图表可以具有当前时间标记", - "expressionXY.reusable.function.xyVis.errors.valueLabelsForNotBarsOrHistogramBarsChartsError": "`valueLabels` 参数仅适用于条形图,它们并非直方图。", "expressionXY.xAxisConfigFn.help": "配置 xy 图表的 x 轴配置", "expressionXY.xyChart.emptyXLabel": "(空)", "expressionXY.xyChart.iconSelect.alertIconLabel": "告警", diff --git a/x-pack/test/functional/apps/lens/group1/smokescreen.ts b/x-pack/test/functional/apps/lens/group1/smokescreen.ts index 83e385d0dee0..c01fd3a848aa 100644 --- a/x-pack/test/functional/apps/lens/group1/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/group1/smokescreen.ts @@ -263,7 +263,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // check for value labels data = await PageObjects.lens.getCurrentChartDebugState('xyVisChart'); - expect(data?.bars?.[0].labels.length).to.eql(0); + expect(data?.bars?.[0].labels).not.to.eql(0); }); it('should override axis title', async () => { From a921696fbe9108a896d8f53033b8654af84af030 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Thu, 20 Oct 2022 07:27:40 -0500 Subject: [PATCH 40/43] use getIndexPattern instead of title (#143686) --- .../explain_log_rate_spikes_analysis.tsx | 2 +- .../full_time_range_selector_service.ts | 2 +- x-pack/plugins/aiops/public/hooks/use_data.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_analysis.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_analysis.tsx index 74be1a0f048f..ca7134f6f09f 100644 --- a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_analysis.tsx +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_analysis.tsx @@ -93,7 +93,7 @@ export const ExplainLogRateSpikesAnalysis: FC searchQuery: JSON.stringify(searchQuery), // TODO Handle data view without time fields. timeFieldName: dataView.timeFieldName ?? '', - index: dataView.title, + index: dataView.getIndexPattern(), grouping: true, flushFix: true, ...windowParameters, diff --git a/x-pack/plugins/aiops/public/components/full_time_range_selector/full_time_range_selector_service.ts b/x-pack/plugins/aiops/public/components/full_time_range_selector/full_time_range_selector_service.ts index f6d6488567f1..a363692f29c7 100644 --- a/x-pack/plugins/aiops/public/components/full_time_range_selector/full_time_range_selector_service.ts +++ b/x-pack/plugins/aiops/public/components/full_time_range_selector/full_time_range_selector_service.ts @@ -40,7 +40,7 @@ export async function setFullTimeRange( ): Promise { const runtimeMappings = dataView.getRuntimeMappings(); const resp = await getTimeFieldRange({ - index: dataView.title, + index: dataView.getIndexPattern(), timeFieldName: dataView.timeFieldName, query: excludeFrozenData ? addExcludeFrozenToQuery(query) : query, ...(isPopulatedObject(runtimeMappings) ? { runtimeMappings } : {}), diff --git a/x-pack/plugins/aiops/public/hooks/use_data.ts b/x-pack/plugins/aiops/public/hooks/use_data.ts index 9d8f889dd847..11977c254300 100644 --- a/x-pack/plugins/aiops/public/hooks/use_data.ts +++ b/x-pack/plugins/aiops/public/hooks/use_data.ts @@ -148,7 +148,7 @@ export const useData = ( earliest: timefilterActiveBounds.min?.valueOf(), latest: timefilterActiveBounds.max?.valueOf(), intervalMs: _timeBuckets.getInterval()?.asMilliseconds(), - index: currentDataView.title, + index: currentDataView.getIndexPattern(), searchQuery, timeFieldName: currentDataView.timeFieldName, runtimeFieldMap: currentDataView.getRuntimeMappings(), From 8bbb43ff89cc84456105cb7e5a9ed430ead92c25 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 20 Oct 2022 08:53:00 -0400 Subject: [PATCH 41/43] feat(slo): Handle timeslices budgeting method (#143467) --- .../server/domain/services/date_range.test.ts | 2 +- .../server/domain/services/date_range.ts | 24 +- .../server/domain/services/index.ts | 1 + .../domain/services/validate_slo.test.ts | 147 ++++++ .../server/domain/services/validate_slo.ts | 61 +++ .../observability/server/errors/errors.ts | 1 + .../server/services/slo/create_slo.ts | 2 + .../server/services/slo/fixtures/slo.ts | 7 +- .../server/services/slo/sli_client.test.ts | 429 ++++++++++++++---- .../server/services/slo/sli_client.ts | 136 ++++-- .../server/services/slo/update_slo.ts | 2 + .../server/types/models/duration.test.ts | 45 ++ .../server/types/models/duration.ts | 59 +++ .../server/types/models/index.ts | 1 + .../server/types/schema/duration.ts | 22 +- .../server/types/schema/schema.test.ts | 15 - .../observability/server/types/schema/slo.ts | 23 +- 17 files changed, 786 insertions(+), 191 deletions(-) create mode 100644 x-pack/plugins/observability/server/domain/services/validate_slo.test.ts create mode 100644 x-pack/plugins/observability/server/domain/services/validate_slo.ts create mode 100644 x-pack/plugins/observability/server/types/models/duration.test.ts create mode 100644 x-pack/plugins/observability/server/types/models/duration.ts diff --git a/x-pack/plugins/observability/server/domain/services/date_range.test.ts b/x-pack/plugins/observability/server/domain/services/date_range.test.ts index 3553b04fa16e..e4a8cdac4593 100644 --- a/x-pack/plugins/observability/server/domain/services/date_range.test.ts +++ b/x-pack/plugins/observability/server/domain/services/date_range.test.ts @@ -6,7 +6,7 @@ */ import { TimeWindow } from '../../types/models/time_window'; -import { Duration, DurationUnit } from '../../types/schema'; +import { Duration, DurationUnit } from '../../types/models'; import { toDateRange } from './date_range'; const THIRTY_DAYS = new Duration(30, DurationUnit.d); diff --git a/x-pack/plugins/observability/server/domain/services/date_range.ts b/x-pack/plugins/observability/server/domain/services/date_range.ts index adb2d3420bc9..e556d85a09f6 100644 --- a/x-pack/plugins/observability/server/domain/services/date_range.ts +++ b/x-pack/plugins/observability/server/domain/services/date_range.ts @@ -7,13 +7,10 @@ import { assertNever } from '@kbn/std'; import moment from 'moment'; +import { toMomentUnitOfTime } from '../../types/models'; import type { TimeWindow } from '../../types/models/time_window'; -import { - calendarAlignedTimeWindowSchema, - DurationUnit, - rollingTimeWindowSchema, -} from '../../types/schema'; +import { calendarAlignedTimeWindowSchema, rollingTimeWindowSchema } from '../../types/schema'; export interface DateRange { from: Date; @@ -49,20 +46,3 @@ export const toDateRange = (timeWindow: TimeWindow, currentDate: Date = new Date assertNever(timeWindow); }; - -const toMomentUnitOfTime = (unit: DurationUnit): moment.unitOfTime.Diff => { - switch (unit) { - case DurationUnit.d: - return 'days'; - case DurationUnit.w: - return 'weeks'; - case DurationUnit.M: - return 'months'; - case DurationUnit.Q: - return 'quarters'; - case DurationUnit.Y: - return 'years'; - default: - assertNever(unit); - } -}; diff --git a/x-pack/plugins/observability/server/domain/services/index.ts b/x-pack/plugins/observability/server/domain/services/index.ts index 02bae9354dc0..8fd5a1e24b77 100644 --- a/x-pack/plugins/observability/server/domain/services/index.ts +++ b/x-pack/plugins/observability/server/domain/services/index.ts @@ -8,3 +8,4 @@ export * from './compute_error_budget'; export * from './compute_sli'; export * from './date_range'; +export * from './validate_slo'; diff --git a/x-pack/plugins/observability/server/domain/services/validate_slo.test.ts b/x-pack/plugins/observability/server/domain/services/validate_slo.test.ts new file mode 100644 index 000000000000..2a1142aa2207 --- /dev/null +++ b/x-pack/plugins/observability/server/domain/services/validate_slo.test.ts @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { validateSLO } from '.'; +import { createSLO } from '../../services/slo/fixtures/slo'; +import { Duration, DurationUnit } from '../../types/models/duration'; + +describe('validateSLO', () => { + describe('any slo', () => { + it("throws when 'objective.target' is lte 0", () => { + const slo = createSLO({ objective: { target: 0 } }); + expect(() => validateSLO(slo)).toThrowError('Invalid objective.target'); + }); + + it("throws when 'objective.target' is gt 1", () => { + const slo = createSLO({ objective: { target: 1.0001 } }); + expect(() => validateSLO(slo)).toThrowError('Invalid objective.target'); + }); + + it("throws when time window duration unit is 'm'", () => { + const slo = createSLO({ + time_window: { duration: new Duration(1, DurationUnit.m), is_rolling: true }, + }); + expect(() => validateSLO(slo)).toThrowError('Invalid time_window.duration'); + }); + + it("throws when time window duration unit is 'h'", () => { + const slo = createSLO({ + time_window: { duration: new Duration(1, DurationUnit.h), is_rolling: true }, + }); + expect(() => validateSLO(slo)).toThrowError('Invalid time_window.duration'); + }); + }); + + describe('slo with timeslices budgeting method', () => { + it("throws when 'objective.timeslice_target' is not present", () => { + const slo = createSLO({ + budgeting_method: 'timeslices', + objective: { + target: 0.95, + timeslice_window: new Duration(1, DurationUnit.m), + }, + }); + expect(() => validateSLO(slo)).toThrowError('Invalid objective.timeslice_target'); + }); + + it("throws when 'objective.timeslice_target' is lte 0", () => { + const slo = createSLO({ + budgeting_method: 'timeslices', + objective: { + target: 0.95, + timeslice_target: 0, + timeslice_window: new Duration(1, DurationUnit.m), + }, + }); + + expect(() => validateSLO(slo)).toThrowError('Invalid objective.timeslice_target'); + }); + + it("throws when 'objective.timeslice_target' is gt 1", () => { + const slo = createSLO({ + budgeting_method: 'timeslices', + objective: { + target: 0.95, + timeslice_target: 1.001, + timeslice_window: new Duration(1, DurationUnit.m), + }, + }); + expect(() => validateSLO(slo)).toThrowError('Invalid objective.timeslice_target'); + }); + + it("throws when 'objective.timeslice_window' is not present", () => { + const slo = createSLO({ + budgeting_method: 'timeslices', + objective: { + target: 0.95, + timeslice_target: 0.95, + }, + }); + + expect(() => validateSLO(slo)).toThrowError('Invalid objective.timeslice_window'); + }); + + it("throws when 'objective.timeslice_window' is not in minutes or hours", () => { + const slo = createSLO({ + budgeting_method: 'timeslices', + objective: { + target: 0.95, + timeslice_target: 0.95, + }, + }); + + expect(() => + validateSLO({ + ...slo, + objective: { ...slo.objective, timeslice_window: new Duration(1, DurationUnit.d) }, + }) + ).toThrowError('Invalid objective.timeslice_window'); + + expect(() => + validateSLO({ + ...slo, + objective: { ...slo.objective, timeslice_window: new Duration(1, DurationUnit.w) }, + }) + ).toThrowError('Invalid objective.timeslice_window'); + + expect(() => + validateSLO({ + ...slo, + objective: { ...slo.objective, timeslice_window: new Duration(1, DurationUnit.M) }, + }) + ).toThrowError('Invalid objective.timeslice_window'); + + expect(() => + validateSLO({ + ...slo, + objective: { ...slo.objective, timeslice_window: new Duration(1, DurationUnit.Q) }, + }) + ).toThrowError('Invalid objective.timeslice_window'); + + expect(() => + validateSLO({ + ...slo, + objective: { ...slo.objective, timeslice_window: new Duration(1, DurationUnit.Y) }, + }) + ).toThrowError('Invalid objective.timeslice_window'); + }); + + it("throws when 'objective.timeslice_window' is longer than 'slo.time_window'", () => { + const slo = createSLO({ + time_window: { duration: new Duration(1, DurationUnit.w), is_rolling: true }, + budgeting_method: 'timeslices', + objective: { + target: 0.95, + timeslice_target: 0.95, + timeslice_window: new Duration(169, DurationUnit.h), // 1 week + 1 hours = 169 hours + }, + }); + + expect(() => validateSLO(slo)).toThrowError('Invalid objective.timeslice_window'); + }); + }); +}); diff --git a/x-pack/plugins/observability/server/domain/services/validate_slo.ts b/x-pack/plugins/observability/server/domain/services/validate_slo.ts new file mode 100644 index 000000000000..db9c1cbf97dd --- /dev/null +++ b/x-pack/plugins/observability/server/domain/services/validate_slo.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IllegalArgumentError } from '../../errors'; +import { SLO } from '../../types/models'; +import { Duration, DurationUnit } from '../../types/models/duration'; +import { timeslicesBudgetingMethodSchema } from '../../types/schema'; + +/** + * Asserts the SLO is valid from a business invariants point of view. + * e.g. a 'target' objective requires a number between ]0, 1] + * e.g. a 'timeslices' budgeting method requires an objective's timeslice_target to be defined. + * + * @param slo {SLO} + */ +export function validateSLO(slo: SLO) { + if (!isValidTargetNumber(slo.objective.target)) { + throw new IllegalArgumentError('Invalid objective.target'); + } + + if (!isValidTimeWindowDuration(slo.time_window.duration)) { + throw new IllegalArgumentError('Invalid time_window.duration'); + } + + if (timeslicesBudgetingMethodSchema.is(slo.budgeting_method)) { + if ( + slo.objective.timeslice_target === undefined || + !isValidTargetNumber(slo.objective.timeslice_target) + ) { + throw new IllegalArgumentError('Invalid objective.timeslice_target'); + } + + if ( + slo.objective.timeslice_window === undefined || + !isValidTimesliceWindowDuration(slo.objective.timeslice_window, slo.time_window.duration) + ) { + throw new IllegalArgumentError('Invalid objective.timeslice_window'); + } + } +} + +function isValidTargetNumber(value: number): boolean { + return value > 0 && value <= 1; +} + +function isValidTimeWindowDuration(duration: Duration): boolean { + return [DurationUnit.d, DurationUnit.w, DurationUnit.M, DurationUnit.Q, DurationUnit.Y].includes( + duration.unit + ); +} + +function isValidTimesliceWindowDuration(timesliceWindow: Duration, timeWindow: Duration): boolean { + return ( + [DurationUnit.m, DurationUnit.h].includes(timesliceWindow.unit) && + timesliceWindow.isShorterThan(timeWindow) + ); +} diff --git a/x-pack/plugins/observability/server/errors/errors.ts b/x-pack/plugins/observability/server/errors/errors.ts index a83a0c31610e..8f04cc58bc4d 100644 --- a/x-pack/plugins/observability/server/errors/errors.ts +++ b/x-pack/plugins/observability/server/errors/errors.ts @@ -17,3 +17,4 @@ export class ObservabilityError extends Error { export class SLONotFound extends ObservabilityError {} export class InternalQueryError extends ObservabilityError {} export class NotSupportedError extends ObservabilityError {} +export class IllegalArgumentError extends ObservabilityError {} diff --git a/x-pack/plugins/observability/server/services/slo/create_slo.ts b/x-pack/plugins/observability/server/services/slo/create_slo.ts index 35a908a48bd2..6d79a6906fc8 100644 --- a/x-pack/plugins/observability/server/services/slo/create_slo.ts +++ b/x-pack/plugins/observability/server/services/slo/create_slo.ts @@ -12,6 +12,7 @@ import { ResourceInstaller } from './resource_installer'; import { SLORepository } from './slo_repository'; import { TransformManager } from './transform_manager'; import { CreateSLOParams, CreateSLOResponse } from '../../types/rest_specs'; +import { validateSLO } from '../../domain/services'; export class CreateSLO { constructor( @@ -22,6 +23,7 @@ export class CreateSLO { public async execute(params: CreateSLOParams): Promise { const slo = this.toSLO(params); + validateSLO(slo); await this.resourceInstaller.ensureCommonResourcesInstalled(); await this.repository.save(slo); diff --git a/x-pack/plugins/observability/server/services/slo/fixtures/slo.ts b/x-pack/plugins/observability/server/services/slo/fixtures/slo.ts index 58846f16a516..a6d46b0689bb 100644 --- a/x-pack/plugins/observability/server/services/slo/fixtures/slo.ts +++ b/x-pack/plugins/observability/server/services/slo/fixtures/slo.ts @@ -5,9 +5,10 @@ * 2.0. */ +import { cloneDeep } from 'lodash'; import uuid from 'uuid'; +import { Duration, DurationUnit } from '../../../types/models/duration'; -import { Duration, DurationUnit } from '../../../types/schema'; import { APMTransactionDurationIndicator, APMTransactionErrorRateIndicator, @@ -65,14 +66,14 @@ export const createSLOParams = (params: Partial = {}): CreateSL export const createSLO = (params: Partial = {}): SLO => { const now = new Date(); - return { + return cloneDeep({ ...defaultSLO, id: uuid.v1(), revision: 1, created_at: now, updated_at: now, ...params, - }; + }); }; export const createSLOWithCalendarTimeWindow = (params: Partial = {}): SLO => { diff --git a/x-pack/plugins/observability/server/services/slo/sli_client.test.ts b/x-pack/plugins/observability/server/services/slo/sli_client.test.ts index 7b0d0a04ab7a..7729bab2f905 100644 --- a/x-pack/plugins/observability/server/services/slo/sli_client.test.ts +++ b/x-pack/plugins/observability/server/services/slo/sli_client.test.ts @@ -7,10 +7,10 @@ import { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; import { SLO_DESTINATION_INDEX_NAME } from '../../assets/constants'; -import { toDateRange } from '../../domain/services/date_range'; +import { toDateRange } from '../../domain/services'; import { InternalQueryError } from '../../errors'; -import { Duration, DurationUnit } from '../../types/schema'; -import { createAPMTransactionErrorRateIndicator, createSLO } from './fixtures/slo'; +import { Duration, DurationUnit } from '../../types/models'; +import { createSLO } from './fixtures/slo'; import { DefaultSLIClient } from './sli_client'; describe('SLIClient', () => { @@ -21,31 +21,8 @@ describe('SLIClient', () => { }); describe('fetchCurrentSLIData', () => { - it('throws when aggregations failed', async () => { - const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() }); - esClientMock.search.mockResolvedValueOnce({ - took: 100, - timed_out: false, - _shards: { - total: 0, - successful: 0, - skipped: 0, - failed: 0, - }, - hits: { - hits: [], - }, - aggregations: {}, - }); - const sliClient = new DefaultSLIClient(esClientMock); - - await expect(sliClient.fetchCurrentSLIData(slo)).rejects.toThrowError( - new InternalQueryError('SLI aggregation query') - ); - }); - - describe('For a rolling time window SLO type', () => { - it('returns the aggregated good and total values', async () => { + describe('for SLO defined with occurrences budgeting method', () => { + it('throws when aggregations failed', async () => { const slo = createSLO({ time_window: { duration: new Duration(7, DurationUnit.d), @@ -64,53 +41,144 @@ describe('SLIClient', () => { hits: { hits: [], }, - aggregations: { - full_window: { buckets: [{ good: { value: 90 }, total: { value: 100 } }] }, - }, + aggregations: {}, }); const sliClient = new DefaultSLIClient(esClientMock); - const result = await sliClient.fetchCurrentSLIData(slo); - - expect(result).toEqual({ good: 90, total: 100 }); - expect(esClientMock.search).toHaveBeenCalledWith( - expect.objectContaining({ - index: `${SLO_DESTINATION_INDEX_NAME}*`, - query: { - bool: { - filter: [ - { term: { 'slo.id': slo.id } }, - { term: { 'slo.revision': slo.revision } }, - ], - }, + await expect(sliClient.fetchCurrentSLIData(slo)).rejects.toThrowError( + new InternalQueryError('SLI aggregation query') + ); + }); + + describe('for a rolling time window SLO type', () => { + it('returns the aggregated good and total values', async () => { + const slo = createSLO({ + time_window: { + duration: new Duration(7, DurationUnit.d), + is_rolling: true, }, - aggs: { - full_window: { - date_range: { - field: '@timestamp', - ranges: [{ from: 'now-7d/m', to: 'now/m' }], - }, - aggs: { - good: { sum: { field: 'slo.numerator' } }, - total: { sum: { field: 'slo.denominator' } }, + }); + esClientMock.search.mockResolvedValueOnce({ + took: 100, + timed_out: false, + _shards: { + total: 0, + successful: 0, + skipped: 0, + failed: 0, + }, + hits: { + hits: [], + }, + aggregations: { + good: { value: 90 }, + total: { value: 100 }, + }, + }); + const sliClient = new DefaultSLIClient(esClientMock); + + const result = await sliClient.fetchCurrentSLIData(slo); + + expect(result).toEqual({ good: 90, total: 100 }); + expect(esClientMock.search).toHaveBeenCalledWith( + expect.objectContaining({ + index: `${SLO_DESTINATION_INDEX_NAME}*`, + query: { + bool: { + filter: [ + { term: { 'slo.id': slo.id } }, + { term: { 'slo.revision': slo.revision } }, + { + range: { + '@timestamp': { gte: 'now-7d/m', lt: 'now/m' }, + }, + }, + ], }, }, + aggs: { + good: { sum: { field: 'slo.numerator' } }, + total: { sum: { field: 'slo.denominator' } }, + }, + }) + ); + }); + }); + + describe('for a calendar aligned time window SLO type', () => { + it('returns the aggregated good and total values', async () => { + const slo = createSLO({ + time_window: { + duration: new Duration(1, DurationUnit.M), + calendar: { + start_time: new Date('2022-09-01T00:00:00.000Z'), + }, }, - }) - ); + }); + esClientMock.search.mockResolvedValueOnce({ + took: 100, + timed_out: false, + _shards: { + total: 0, + successful: 0, + skipped: 0, + failed: 0, + }, + hits: { + hits: [], + }, + aggregations: { + good: { value: 90 }, + total: { value: 100 }, + }, + }); + const sliClient = new DefaultSLIClient(esClientMock); + + const result = await sliClient.fetchCurrentSLIData(slo); + + const expectedDateRange = toDateRange(slo.time_window); + + expect(result).toEqual({ good: 90, total: 100 }); + expect(esClientMock.search).toHaveBeenCalledWith( + expect.objectContaining({ + index: `${SLO_DESTINATION_INDEX_NAME}*`, + query: { + bool: { + filter: [ + { term: { 'slo.id': slo.id } }, + { term: { 'slo.revision': slo.revision } }, + { + range: { + '@timestamp': { + gte: expectedDateRange.from.toISOString(), + lt: expectedDateRange.to.toISOString(), + }, + }, + }, + ], + }, + }, + aggs: { + good: { sum: { field: 'slo.numerator' } }, + total: { sum: { field: 'slo.denominator' } }, + }, + }) + ); + }); }); }); - describe('For a calendar aligned time window SLO type', () => { - it('returns the aggregated good and total values', async () => { + describe('for SLO defined with timeslices budgeting method', () => { + it('throws when aggregations failed', async () => { const slo = createSLO({ - time_window: { - duration: new Duration(1, DurationUnit.M), - calendar: { - start_time: new Date('2022-09-01T00:00:00.000Z'), - }, + budgeting_method: 'timeslices', + objective: { + target: 0.95, + timeslice_target: 0.95, + timeslice_window: new Duration(10, DurationUnit.m), }, }); + esClientMock.search.mockResolvedValueOnce({ took: 100, timed_out: false, @@ -123,47 +191,228 @@ describe('SLIClient', () => { hits: { hits: [], }, - aggregations: { - full_window: { buckets: [{ good: { value: 90 }, total: { value: 100 } }] }, - }, + aggregations: {}, }); const sliClient = new DefaultSLIClient(esClientMock); - const result = await sliClient.fetchCurrentSLIData(slo); - - const expectedDateRange = toDateRange(slo.time_window); + await expect(sliClient.fetchCurrentSLIData(slo)).rejects.toThrowError( + new InternalQueryError('SLI aggregation query') + ); + }); - expect(result).toEqual({ good: 90, total: 100 }); - expect(esClientMock.search).toHaveBeenCalledWith( - expect.objectContaining({ - index: `${SLO_DESTINATION_INDEX_NAME}*`, - query: { - bool: { - filter: [ - { term: { 'slo.id': slo.id } }, - { term: { 'slo.revision': slo.revision } }, - ], + describe('for a calendar aligned time window SLO type', () => { + it('returns the aggregated good and total values', async () => { + const slo = createSLO({ + budgeting_method: 'timeslices', + objective: { + target: 0.95, + timeslice_target: 0.9, + timeslice_window: new Duration(10, DurationUnit.m), + }, + time_window: { + duration: new Duration(1, DurationUnit.M), + calendar: { + start_time: new Date('2022-09-01T00:00:00.000Z'), }, }, - aggs: { - full_window: { - date_range: { - field: '@timestamp', - ranges: [ + }); + esClientMock.search.mockResolvedValueOnce({ + took: 100, + timed_out: false, + _shards: { + total: 0, + successful: 0, + skipped: 0, + failed: 0, + }, + hits: { + hits: [], + }, + aggregations: { + slices: { buckets: [] }, + good: { value: 90 }, + total: { value: 100 }, + }, + }); + const sliClient = new DefaultSLIClient(esClientMock); + + const result = await sliClient.fetchCurrentSLIData(slo); + + const expectedDateRange = toDateRange(slo.time_window); + + expect(result).toEqual({ good: 90, total: 100 }); + expect(esClientMock.search).toHaveBeenCalledWith( + expect.objectContaining({ + index: `${SLO_DESTINATION_INDEX_NAME}*`, + query: { + bool: { + filter: [ + { term: { 'slo.id': slo.id } }, + { term: { 'slo.revision': slo.revision } }, { - from: expectedDateRange.from.toISOString(), - to: expectedDateRange.to.toISOString(), + range: { + '@timestamp': { + gte: expectedDateRange.from.toISOString(), + lt: expectedDateRange.to.toISOString(), + }, + }, }, ], }, - aggs: { - good: { sum: { field: 'slo.numerator' } }, - total: { sum: { field: 'slo.denominator' } }, + }, + aggs: { + slices: { + date_histogram: { + field: '@timestamp', + fixed_interval: '10m', + }, + aggs: { + good: { + sum: { + field: 'slo.numerator', + }, + }, + total: { + sum: { + field: 'slo.denominator', + }, + }, + good_slice: { + bucket_script: { + buckets_path: { + good: 'good', + total: 'total', + }, + script: `params.good / params.total >= ${slo.objective.timeslice_target} ? 1 : 0`, + }, + }, + count_slice: { + bucket_script: { + buckets_path: {}, + script: '1', + }, + }, + }, + }, + good: { + sum_bucket: { + buckets_path: 'slices>good_slice.value', + }, + }, + total: { + sum_bucket: { + buckets_path: 'slices>count_slice.value', + }, }, }, + }) + ); + }); + }); + + describe('for a rolling time window SLO type', () => { + it('returns the aggregated good and total values', async () => { + const slo = createSLO({ + budgeting_method: 'timeslices', + objective: { + target: 0.95, + timeslice_target: 0.9, + timeslice_window: new Duration(10, DurationUnit.m), }, - }) - ); + time_window: { + duration: new Duration(1, DurationUnit.M), + is_rolling: true, + }, + }); + esClientMock.search.mockResolvedValueOnce({ + took: 100, + timed_out: false, + _shards: { + total: 0, + successful: 0, + skipped: 0, + failed: 0, + }, + hits: { + hits: [], + }, + aggregations: { + good: { value: 90 }, + total: { value: 100 }, + }, + }); + const sliClient = new DefaultSLIClient(esClientMock); + + const result = await sliClient.fetchCurrentSLIData(slo); + + expect(result).toEqual({ good: 90, total: 100 }); + expect(esClientMock.search).toHaveBeenCalledWith( + expect.objectContaining({ + index: `${SLO_DESTINATION_INDEX_NAME}*`, + query: { + bool: { + filter: [ + { term: { 'slo.id': slo.id } }, + { term: { 'slo.revision': slo.revision } }, + { + range: { + '@timestamp': { + gte: 'now-1M/m', + lt: 'now/m', + }, + }, + }, + ], + }, + }, + aggs: { + slices: { + date_histogram: { + field: '@timestamp', + fixed_interval: '10m', + }, + aggs: { + good: { + sum: { + field: 'slo.numerator', + }, + }, + total: { + sum: { + field: 'slo.denominator', + }, + }, + good_slice: { + bucket_script: { + buckets_path: { + good: 'good', + total: 'total', + }, + script: `params.good / params.total >= ${slo.objective.timeslice_target} ? 1 : 0`, + }, + }, + count_slice: { + bucket_script: { + buckets_path: {}, + script: '1', + }, + }, + }, + }, + good: { + sum_bucket: { + buckets_path: 'slices>good_slice.value', + }, + }, + total: { + sum_bucket: { + buckets_path: 'slices>count_slice.value', + }, + }, + }, + }) + ); + }); }); }); }); diff --git a/x-pack/plugins/observability/server/services/slo/sli_client.ts b/x-pack/plugins/observability/server/services/slo/sli_client.ts index 93d1e7b20782..dc97aa1c651f 100644 --- a/x-pack/plugins/observability/server/services/slo/sli_client.ts +++ b/x-pack/plugins/observability/server/services/slo/sli_client.ts @@ -5,77 +5,143 @@ * 2.0. */ +import { AggregationsSumAggregate } from '@elastic/elasticsearch/lib/api/types'; import { ElasticsearchClient } from '@kbn/core/server'; import { assertNever } from '@kbn/std'; import { SLO_DESTINATION_INDEX_NAME } from '../../assets/constants'; import { toDateRange } from '../../domain/services/date_range'; -import { InternalQueryError, NotSupportedError } from '../../errors'; -import { IndicatorData, SLO } from '../../types/models'; +import { InternalQueryError } from '../../errors'; +import { Duration, IndicatorData, SLO } from '../../types/models'; import { calendarAlignedTimeWindowSchema, rollingTimeWindowSchema } from '../../types/schema'; +import { + occurencesBudgetingMethodSchema, + timeslicesBudgetingMethodSchema, +} from '../../types/schema'; export interface SLIClient { fetchCurrentSLIData(slo: SLO): Promise; } +type AggKey = 'good' | 'total'; + export class DefaultSLIClient implements SLIClient { constructor(private esClient: ElasticsearchClient) {} async fetchCurrentSLIData(slo: SLO): Promise { - if (slo.budgeting_method !== 'occurrences') { - throw new NotSupportedError(`Budgeting method: ${slo.budgeting_method}`); + if (occurencesBudgetingMethodSchema.is(slo.budgeting_method)) { + const result = await this.esClient.search>({ + ...commonQuery(slo), + aggs: { + good: { sum: { field: 'slo.numerator' } }, + total: { sum: { field: 'slo.denominator' } }, + }, + }); + + return handleResult(result.aggregations); } - const result = await this.esClient.search({ - size: 0, - index: `${SLO_DESTINATION_INDEX_NAME}*`, - query: { - bool: { - filter: [{ term: { 'slo.id': slo.id } }, { term: { 'slo.revision': slo.revision } }], - }, - }, - aggs: { - full_window: { - date_range: { - field: '@timestamp', - ranges: [fromSLOTimeWindowToEsRange(slo)], + if (timeslicesBudgetingMethodSchema.is(slo.budgeting_method)) { + const result = await this.esClient.search>({ + ...commonQuery(slo), + aggs: { + slices: { + date_histogram: { + field: '@timestamp', + fixed_interval: toInterval(slo.objective.timeslice_window), + }, + aggs: { + good: { sum: { field: 'slo.numerator' } }, + total: { sum: { field: 'slo.denominator' } }, + good_slice: { + bucket_script: { + buckets_path: { + good: 'good', + total: 'total', + }, + script: `params.good / params.total >= ${slo.objective.timeslice_target} ? 1 : 0`, + }, + }, + count_slice: { + bucket_script: { + buckets_path: {}, + script: '1', + }, + }, + }, }, - aggs: { - good: { sum: { field: 'slo.numerator' } }, - total: { sum: { field: 'slo.denominator' } }, + good: { + sum_bucket: { + buckets_path: 'slices>good_slice.value', + }, + }, + total: { + sum_bucket: { + buckets_path: 'slices>count_slice.value', + }, }, }, - }, - }); + }); - // @ts-ignore buckets is not recognized - const aggs = result.aggregations?.full_window?.buckets[0]; - if (aggs === undefined) { - throw new InternalQueryError('SLI aggregation query'); + return handleResult(result.aggregations); } - return { - good: aggs.good.value, - total: aggs.total.value, - }; + assertNever(slo.budgeting_method); } } -function fromSLOTimeWindowToEsRange(slo: SLO): { from: string; to: string } { +function fromSLOTimeWindowToEsRange(slo: SLO): { gte: string; lt: string } { if (calendarAlignedTimeWindowSchema.is(slo.time_window)) { const dateRange = toDateRange(slo.time_window); return { - from: `${dateRange.from.toISOString()}`, - to: `${dateRange.to.toISOString()}`, + gte: `${dateRange.from.toISOString()}`, + lt: `${dateRange.to.toISOString()}`, }; } if (rollingTimeWindowSchema.is(slo.time_window)) { return { - from: `now-${slo.time_window.duration.value}${slo.time_window.duration.unit}/m`, - to: `now/m`, + gte: `now-${slo.time_window.duration.value}${slo.time_window.duration.unit}/m`, + lt: `now/m`, }; } assertNever(slo.time_window); } + +function commonQuery(slo: SLO) { + return { + size: 0, + index: `${SLO_DESTINATION_INDEX_NAME}*`, + query: { + bool: { + filter: [ + { term: { 'slo.id': slo.id } }, + { term: { 'slo.revision': slo.revision } }, + { range: { '@timestamp': fromSLOTimeWindowToEsRange(slo) } }, + ], + }, + }, + }; +} + +function handleResult( + aggregations: Record | undefined +): IndicatorData { + const good = aggregations?.good; + const total = aggregations?.total; + if (good === undefined || good.value === null || total === undefined || total.value === null) { + throw new InternalQueryError('SLI aggregation query'); + } + + return { + good: good.value, + total: total.value, + }; +} + +function toInterval(duration: Duration | undefined): string { + if (duration === undefined) return '1m'; + + return `${duration.value}${duration.unit}`; +} diff --git a/x-pack/plugins/observability/server/services/slo/update_slo.ts b/x-pack/plugins/observability/server/services/slo/update_slo.ts index e356cb4701c8..9daf1596e442 100644 --- a/x-pack/plugins/observability/server/services/slo/update_slo.ts +++ b/x-pack/plugins/observability/server/services/slo/update_slo.ts @@ -17,6 +17,7 @@ import { import { SLORepository } from './slo_repository'; import { TransformManager } from './transform_manager'; import { SLO } from '../../types/models'; +import { validateSLO } from '../../domain/services'; export class UpdateSLO { constructor( @@ -45,6 +46,7 @@ export class UpdateSLO { private updateSLO(originalSlo: SLO, params: UpdateSLOParams) { let hasBreakingChange = false; const updatedSlo: SLO = Object.assign({}, originalSlo, params, { updated_at: new Date() }); + validateSLO(updatedSlo); if (!deepEqual(originalSlo.indicator, updatedSlo.indicator)) { hasBreakingChange = true; diff --git a/x-pack/plugins/observability/server/types/models/duration.test.ts b/x-pack/plugins/observability/server/types/models/duration.test.ts new file mode 100644 index 000000000000..4383c8e3ddd7 --- /dev/null +++ b/x-pack/plugins/observability/server/types/models/duration.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Duration, DurationUnit } from './duration'; + +describe('Duration', () => { + it('throws when value is negative', () => { + expect(() => new Duration(-1, DurationUnit.d)).toThrow('invalid duration value'); + }); + + it('throws when value is zero', () => { + expect(() => new Duration(0, DurationUnit.d)).toThrow('invalid duration value'); + }); + + it('throws when unit is not valid', () => { + expect(() => new Duration(1, 'z' as DurationUnit)).toThrow('invalid duration unit'); + }); + + describe('isShorterThan', () => { + it('returns true when the current duration is shorter than the other duration', () => { + const short = new Duration(1, DurationUnit.m); + expect(short.isShorterThan(new Duration(1, DurationUnit.h))).toBe(true); + expect(short.isShorterThan(new Duration(1, DurationUnit.d))).toBe(true); + expect(short.isShorterThan(new Duration(1, DurationUnit.w))).toBe(true); + expect(short.isShorterThan(new Duration(1, DurationUnit.M))).toBe(true); + expect(short.isShorterThan(new Duration(1, DurationUnit.Q))).toBe(true); + expect(short.isShorterThan(new Duration(1, DurationUnit.Y))).toBe(true); + }); + + it('returns false when the current duration is longer (or equal) than the other duration', () => { + const long = new Duration(1, DurationUnit.Y); + expect(long.isShorterThan(new Duration(1, DurationUnit.m))).toBe(false); + expect(long.isShorterThan(new Duration(1, DurationUnit.h))).toBe(false); + expect(long.isShorterThan(new Duration(1, DurationUnit.d))).toBe(false); + expect(long.isShorterThan(new Duration(1, DurationUnit.w))).toBe(false); + expect(long.isShorterThan(new Duration(1, DurationUnit.M))).toBe(false); + expect(long.isShorterThan(new Duration(1, DurationUnit.Q))).toBe(false); + expect(long.isShorterThan(new Duration(1, DurationUnit.Y))).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/observability/server/types/models/duration.ts b/x-pack/plugins/observability/server/types/models/duration.ts new file mode 100644 index 000000000000..e34a748e30ba --- /dev/null +++ b/x-pack/plugins/observability/server/types/models/duration.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { assertNever } from '@kbn/std'; +import * as moment from 'moment'; + +enum DurationUnit { + 'm' = 'm', + 'h' = 'h', + 'd' = 'd', + 'w' = 'w', + 'M' = 'M', + 'Q' = 'Q', + 'Y' = 'Y', +} + +class Duration { + constructor(public readonly value: number, public readonly unit: DurationUnit) { + if (isNaN(value) || value <= 0) { + throw new Error('invalid duration value'); + } + if (!Object.values(DurationUnit).includes(unit as unknown as DurationUnit)) { + throw new Error('invalid duration unit'); + } + } + + isShorterThan(other: Duration): boolean { + const otherDurationMoment = moment.duration(other.value, toMomentUnitOfTime(other.unit)); + const currentDurationMoment = moment.duration(this.value, toMomentUnitOfTime(this.unit)); + return currentDurationMoment.asSeconds() < otherDurationMoment.asSeconds(); + } +} + +const toMomentUnitOfTime = (unit: DurationUnit): moment.unitOfTime.Diff => { + switch (unit) { + case DurationUnit.m: + return 'minutes'; + case DurationUnit.h: + return 'hours'; + case DurationUnit.d: + return 'days'; + case DurationUnit.w: + return 'weeks'; + case DurationUnit.M: + return 'months'; + case DurationUnit.Q: + return 'quarters'; + case DurationUnit.Y: + return 'years'; + default: + assertNever(unit); + } +}; + +export { Duration, DurationUnit, toMomentUnitOfTime }; diff --git a/x-pack/plugins/observability/server/types/models/index.ts b/x-pack/plugins/observability/server/types/models/index.ts index 0f83e97aee6f..d27c9f27c868 100644 --- a/x-pack/plugins/observability/server/types/models/index.ts +++ b/x-pack/plugins/observability/server/types/models/index.ts @@ -8,3 +8,4 @@ export * from './slo'; export * from './indicators'; export * from './error_budget'; +export * from './duration'; diff --git a/x-pack/plugins/observability/server/types/schema/duration.ts b/x-pack/plugins/observability/server/types/schema/duration.ts index 68f6fad27a77..b4fa5065063f 100644 --- a/x-pack/plugins/observability/server/types/schema/duration.ts +++ b/x-pack/plugins/observability/server/types/schema/duration.ts @@ -7,25 +7,7 @@ import { either } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; - -enum DurationUnit { - 'd' = 'd', - 'w' = 'w', - 'M' = 'M', - 'Q' = 'Q', - 'Y' = 'Y', -} - -class Duration { - constructor(public readonly value: number, public readonly unit: DurationUnit) { - if (isNaN(value) || value <= 0) { - throw new Error('invalid duration value'); - } - if (!Object.values(DurationUnit).includes(unit as unknown as DurationUnit)) { - throw new Error('invalid duration unit'); - } - } -} +import { Duration, DurationUnit } from '../models/duration'; const durationType = new t.Type( 'Duration', @@ -45,4 +27,4 @@ const durationType = new t.Type( (duration: Duration): string => `${duration.value}${duration.unit}` ); -export { Duration, DurationUnit, durationType }; +export { durationType }; diff --git a/x-pack/plugins/observability/server/types/schema/schema.test.ts b/x-pack/plugins/observability/server/types/schema/schema.test.ts index 69cdb00aac09..1f16c1dfa267 100644 --- a/x-pack/plugins/observability/server/types/schema/schema.test.ts +++ b/x-pack/plugins/observability/server/types/schema/schema.test.ts @@ -10,7 +10,6 @@ import { fold } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; import { dateType } from './common'; -import { Duration, DurationUnit } from './duration'; describe('Schema', () => { describe('DateType', () => { @@ -42,18 +41,4 @@ describe('Schema', () => { ).toThrow(new Error('decode')); }); }); - - describe('Duration', () => { - it('throws when value is negative', () => { - expect(() => new Duration(-1, DurationUnit.d)).toThrow('invalid duration value'); - }); - - it('throws when value is zero', () => { - expect(() => new Duration(0, DurationUnit.d)).toThrow('invalid duration value'); - }); - - it('throws when unit is not valid', () => { - expect(() => new Duration(1, 'z' as DurationUnit)).toThrow('invalid duration unit'); - }); - }); }); diff --git a/x-pack/plugins/observability/server/types/schema/slo.ts b/x-pack/plugins/observability/server/types/schema/slo.ts index fbcf3ae458ad..e9a3a752c884 100644 --- a/x-pack/plugins/observability/server/types/schema/slo.ts +++ b/x-pack/plugins/observability/server/types/schema/slo.ts @@ -6,11 +6,24 @@ */ import * as t from 'io-ts'; +import { durationType } from './duration'; -const budgetingMethodSchema = t.literal('occurrences'); +const occurencesBudgetingMethodSchema = t.literal('occurrences'); +const timeslicesBudgetingMethodSchema = t.literal('timeslices'); -const objectiveSchema = t.type({ - target: t.number, -}); +const budgetingMethodSchema = t.union([ + occurencesBudgetingMethodSchema, + timeslicesBudgetingMethodSchema, +]); -export { budgetingMethodSchema, objectiveSchema }; +const objectiveSchema = t.intersection([ + t.type({ target: t.number }), + t.partial({ timeslice_target: t.number, timeslice_window: durationType }), +]); + +export { + budgetingMethodSchema, + occurencesBudgetingMethodSchema, + timeslicesBudgetingMethodSchema, + objectiveSchema, +}; From f9d9e629286adc25fe50f10109e785f89b935f3c Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 20 Oct 2022 06:58:25 -0600 Subject: [PATCH 42/43] skip failing test suite (#142148) --- .../test/security_solution_endpoint/apps/endpoint/responder.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/responder.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/responder.ts index ddcbbc6251fd..56cbfb782a8c 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/responder.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/responder.ts @@ -76,7 +76,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ); }; - describe('Response Actions Responder', function () { + // Failing: See https://github.com/elastic/kibana/issues/142148 + describe.skip('Response Actions Responder', function () { let indexedData: IndexedHostsAndAlertsResponse; let endpointAgentId: string; From 9a433da32cd70729eb95b293132d52a240699d5d Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 20 Oct 2022 07:02:16 -0600 Subject: [PATCH 43/43] skip failing test suite (#141849) --- .../security_and_spaces/group1/tests/alerting/disable.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/disable.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/disable.ts index df9fc34e1701..a645d8999809 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/disable.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/disable.ts @@ -26,7 +26,8 @@ export default function createDisableAlertTests({ getService }: FtrProviderConte const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); - describe('disable', () => { + // Failing: See https://github.com/elastic/kibana/issues/141849 + describe.skip('disable', () => { const objectRemover = new ObjectRemover(supertest); after(() => objectRemover.removeAll());