From f8df852712e9b9bdad6eddd5660ee35723becbf5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 15 Feb 2022 12:59:18 +0100 Subject: [PATCH 01/39] Update dependency broadcast-channel to ^4.10.0 (#124740) Co-authored-by: Renovate Bot Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 27368b2f4e4f6..d8e2226ba944c 100644 --- a/package.json +++ b/package.json @@ -203,7 +203,7 @@ "base64-js": "^1.3.1", "bitmap-sdf": "^1.0.3", "brace": "0.11.1", - "broadcast-channel": "^4.9.0", + "broadcast-channel": "^4.10.0", "canvg": "^3.0.9", "chalk": "^4.1.0", "cheerio": "^1.0.0-rc.10", diff --git a/yarn.lock b/yarn.lock index cb5038e3f3d72..252b2a8cc6775 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9912,10 +9912,10 @@ broadcast-channel@^3.4.1: rimraf "3.0.2" unload "2.2.0" -broadcast-channel@^4.9.0: - version "4.9.0" - resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-4.9.0.tgz#8af337d4ea19aeb6b819ec2eb3dda942b28c724c" - integrity sha512-xWzFb3wrOZGJF2kOSs2D3KvHXdLDMVb+WypEIoNvwblcHgUBydVy65pDJ9RS4WN9Kyvs0UVQuCCzfKme0G6Qjw== +broadcast-channel@^4.10.0: + version "4.10.0" + resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-4.10.0.tgz#d19fb902df227df40b1b580351713d30c302d198" + integrity sha512-hOUh312XyHk6JTVyX9cyXaH1UYs+2gHVtnW16oQAu9FL7ALcXGXc/YoJWqlkV8vUn14URQPMmRi4A9q4UrwVEQ== dependencies: "@babel/runtime" "^7.16.0" detect-node "^2.1.0" From 8facea9e1dba68141ec9dbb4bf01ae5a941d5a4e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 15 Feb 2022 13:30:25 +0100 Subject: [PATCH 02/39] [Lens] Do not allow rarity in some cases (#125523) * do not allow rarity in some cases * use new params to build label --- .../droppable/droppable.test.ts | 35 +++++++++++++++++++ .../public/indexpattern_datasource/mocks.ts | 1 + .../operations/definitions/terms/index.tsx | 26 ++++++++++++-- .../definitions/terms/terms.test.tsx | 24 +++++++++++++ .../public/indexpattern_datasource/utils.tsx | 10 ++++-- 5 files changed, 91 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index 002fec786d7e6..d676523609bcc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -771,6 +771,41 @@ describe('IndexPatternDimensionEditorPanel', () => { }); }); + it('returns no combine_compatible drop type if the target column uses rarity ordering', () => { + state = getStateWithMultiFieldColumn(); + state.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2'], + columns: { + col1: state.layers.first.columns.col1, + + col2: { + ...state.layers.first.columns.col1, + sourceField: 'bytes', + params: { + ...(state.layers.first.columns.col1 as TermsIndexPatternColumn).params, + orderBy: { type: 'rare' }, + }, + } as TermsIndexPatternColumn, + }, + }; + + expect( + getDropProps({ + ...defaultProps, + state, + groupId, + dragging: { + ...draggingCol1, + groupId: 'c', + }, + columnId: 'col2', + }) + ).toEqual({ + dropTypes: ['replace_compatible', 'replace_duplicate_compatible'], + }); + }); + it('returns no combine drop type if the dragged column is compatible, the target one supports multiple fields but there are too many fields', () => { state = getStateWithMultiFieldColumn(); state.layers.first = { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts index b8b5b9a4e6293..079a866676f04 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts @@ -50,6 +50,7 @@ export const createMockedIndexPattern = (): IndexPattern => { type: 'number', aggregatable: true, searchable: true, + esTypes: ['float'], }, { name: 'source', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index d574f9f6c5d35..78129cc8c1233 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -40,6 +40,13 @@ import { isSortableByColumn, } from './helpers'; +export function supportsRarityRanking(field?: IndexPatternField) { + // these es field types can't be sorted by rarity + return !field?.esTypes?.some((esType) => + ['double', 'float', 'half_float', 'scaled_float'].includes(esType) + ); +} + export type { TermsIndexPatternColumn } from './types'; const missingFieldLabel = i18n.translate('xpack.lens.indexPattern.missingFieldLabel', { @@ -144,7 +151,10 @@ export const termsOperation: OperationDefinition { - // first step: collect the fields from the targetColumn + if (targetColumn.params.orderBy.type === 'rare') { + return false; + } + // collect the fields from the targetColumn const originalTerms = new Set([ targetColumn.sourceField, ...(targetColumn.params?.secondaryFields ?? []), @@ -306,6 +316,9 @@ export const termsOperation: OperationDefinition { ]); }); + it('should disable rare ordering for floating point types', () => { + const updateLayerSpy = jest.fn(); + const instance = shallow( + + ); + + const select = instance.find('[data-test-subj="indexPattern-terms-orderBy"]').find(EuiSelect); + + expect(select.prop('value')).toEqual('alphabetical'); + + expect(select.prop('options')!.map(({ value }) => value)).toEqual([ + 'column$$$col2', + 'alphabetical', + ]); + }); + it('should update state with the order by value', () => { const updateLayerSpy = jest.fn(); const instance = shallow( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx index cc8a5c322782d..d3536c7d0ae29 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx @@ -30,7 +30,7 @@ import { isQueryValid } from './operations/definitions/filters'; import { checkColumnForPrecisionError } from '../../../../../src/plugins/data/common'; import { hasField } from './pure_utils'; import { mergeLayer } from './state_helpers'; -import { DEFAULT_MAX_DOC_COUNT } from './operations/definitions/terms'; +import { DEFAULT_MAX_DOC_COUNT, supportsRarityRanking } from './operations/definitions/terms'; export function isColumnInvalid( layer: IndexPatternLayer, @@ -117,7 +117,13 @@ export function getPrecisionErrorWarningMessages( 'count', currentLayer.columns[currentColumn.params.orderBy.columnId] ); - if (!isAscendingCountSorting) { + const usesFloatingPointField = + isColumnOfType('terms', currentColumn) && + !supportsRarityRanking(indexPattern.getFieldByName(currentColumn.sourceField)); + const usesMultipleFields = + isColumnOfType('terms', currentColumn) && + (currentColumn.params.secondaryFields || []).length > 0; + if (!isAscendingCountSorting || usesFloatingPointField || usesMultipleFields) { warningMessages.push( Date: Tue, 15 Feb 2022 08:15:49 -0500 Subject: [PATCH 03/39] [Fleet] Update copy for integration server (#125592) --- .../components/fleet_server_cloud_instructions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_cloud_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_cloud_instructions.tsx index 7585bd31d57d1..00487d41d25ab 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_cloud_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_cloud_instructions.tsx @@ -43,14 +43,14 @@ export const CloudInstructions: React.FC<{ deploymentUrl: string }> = ({ deploym

} body={ Date: Tue, 15 Feb 2022 13:22:29 +0000 Subject: [PATCH 04/39] [Fleet] Do not mutate package policy update object (#125622) * do not modify object property * add unit test --- .../server/services/package_policy.test.ts | 52 ++++++++++++++++++- .../fleet/server/services/package_policy.ts | 4 +- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index 735f41f499868..92a3e9ac99d2b 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -10,7 +10,7 @@ import { savedObjectsClientMock, httpServerMock, } from 'src/core/server/mocks'; - +import { produce } from 'immer'; import type { SavedObjectsClient, SavedObjectsClientContract, @@ -25,6 +25,7 @@ import type { PostPackagePolicyDeleteCallback, RegistryDataStream, PackagePolicyInputStream, + PackagePolicy, } from '../types'; import { createPackagePolicyMock } from '../../common/mocks'; @@ -949,6 +950,55 @@ describe('Package policy service', () => { expect(result.elasticsearch).toMatchObject({ privileges: { cluster: ['monitor'] } }); }); + + it('should not mutate packagePolicyUpdate object when trimming whitespace', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const mockPackagePolicy = createPackagePolicyMock(); + + const attributes = { + ...mockPackagePolicy, + inputs: [], + }; + + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes, + }); + + savedObjectsClient.update.mockImplementation( + async ( + type: string, + id: string, + attrs: any + ): Promise> => { + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes: attrs, + }); + return attrs; + } + ); + const elasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + const result = await packagePolicyService.update( + savedObjectsClient, + elasticsearchClient, + 'the-package-policy-id', + // this mimics the way that OSQuery plugin create immutable objects + produce( + { ...mockPackagePolicy, name: ' test ', inputs: [] }, + (draft) => draft + ) + ); + + expect(result.name).toEqual('test'); + }); }); describe('runDeleteExternalCallbacks', () => { diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index cb93933bb0d05..641136b89fb30 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -363,11 +363,11 @@ class PackagePolicyService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, id: string, - packagePolicy: UpdatePackagePolicy, + packagePolicyUpdate: UpdatePackagePolicy, options?: { user?: AuthenticatedUser }, currentVersion?: string ): Promise { - packagePolicy.name = packagePolicy.name.trim(); + const packagePolicy = { ...packagePolicyUpdate, name: packagePolicyUpdate.name.trim() }; const oldPackagePolicy = await this.get(soClient, id); const { version, ...restOfPackagePolicy } = packagePolicy; From 724e3b2ebf813aa4da2d9d7496f9a5dfe0361e8b Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Tue, 15 Feb 2022 16:21:37 +0200 Subject: [PATCH 05/39] Reverse parent child context relationship (#125486) * reverse parent child context relationship * bad merge * ts * ts * fix jest * try unblocking flaky test * doc --- ...ugin-core-public.kibanaexecutioncontext.md | 4 +- ...ugin-core-server.kibanaexecutioncontext.md | 4 +- .../user/troubleshooting/trace-query.asciidoc | 20 +- .../execution_context_container.test.ts | 60 +++--- src/core/public/public.api.md | 4 +- .../execution_context_container.test.ts | 77 ++++--- .../execution_context_container.ts | 6 +- .../execution_context_service.test.ts | 44 ++-- .../integration_tests/tracing.test.ts | 4 +- src/core/server/server.api.md | 4 +- src/core/types/execution_context.ts | 6 +- .../embeddable/saved_search_embeddable.tsx | 12 +- .../embeddable/visualize_embeddable.tsx | 12 +- .../lens/public/embeddable/embeddable.tsx | 16 +- .../tests/browser.ts | 201 +++++++++--------- .../tests/server.ts | 20 +- 16 files changed, 266 insertions(+), 228 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md index 8b758715a1975..6266639b63976 100644 --- a/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md @@ -13,8 +13,8 @@ export declare type KibanaExecutionContext = { readonly type: string; readonly name: string; readonly id: string; - readonly description: string; + readonly description?: string; readonly url?: string; - parent?: KibanaExecutionContext; + child?: KibanaExecutionContext; }; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md index db06f9b13f9f6..0d65a3662da6f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md @@ -13,8 +13,8 @@ export declare type KibanaExecutionContext = { readonly type: string; readonly name: string; readonly id: string; - readonly description: string; + readonly description?: string; readonly url?: string; - parent?: KibanaExecutionContext; + child?: KibanaExecutionContext; }; ``` diff --git a/docs/user/troubleshooting/trace-query.asciidoc b/docs/user/troubleshooting/trace-query.asciidoc index f037b26ade630..24f8cc487bf75 100644 --- a/docs/user/troubleshooting/trace-query.asciidoc +++ b/docs/user/troubleshooting/trace-query.asciidoc @@ -29,16 +29,16 @@ Now, you can see the request to {es} has been initiated by the `[Logs] Unique Vi [source,text] ---- [DEBUG][execution_context] stored the execution context: { - "parent": { - "type": "application", - "name": "dashboard", - "id": "edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b", - "description": "[Logs] Web Traffic","url":"/view/edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b" + "type": "application", + "name": "dashboard", + "id": "edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b", + "description": "[Logs] Web Traffic","url":"/view/edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b" + "child": { + "type": "visualization", + "name": "Vega", + "id": "cb099a20-ea66-11eb-9425-113343a037e3", + "description": "[Logs] Unique Visitor Heatmap", + "url": "/app/visualize#/edit/cb099a20-ea66-11eb-9425-113343a037e3" }, - "type": "visualization", - "name": "Vega", - "id": "cb099a20-ea66-11eb-9425-113343a037e3", - "description": "[Logs] Unique Visitor Heatmap", - "url": "/app/visualize#/edit/cb099a20-ea66-11eb-9425-113343a037e3" } ---- diff --git a/src/core/public/execution_context/execution_context_container.test.ts b/src/core/public/execution_context/execution_context_container.test.ts index 5e4e34d102e5b..189dc35e9d730 100644 --- a/src/core/public/execution_context/execution_context_container.test.ts +++ b/src/core/public/execution_context/execution_context_container.test.ts @@ -29,26 +29,26 @@ describe('KibanaExecutionContext', () => { `); }); - it('includes a parent context to string representation', () => { - const parentContext: KibanaExecutionContext = { - type: 'parent-type', - name: 'parent-name', - id: '41', - description: 'parent-descripton', + it('includes a child context to string representation', () => { + const childContext: KibanaExecutionContext = { + type: 'child-test-type', + name: 'child-test-name', + id: '42', + description: 'child-test-descripton', }; const context: KibanaExecutionContext = { - type: 'test-type', - name: 'test-name', - id: '42', - description: 'test-descripton', - parent: parentContext, + type: 'type', + name: 'name', + id: '41', + description: 'descripton', + child: childContext, }; const value = new ExecutionContextContainer(context).toHeader(); expect(value).toMatchInlineSnapshot(` Object { - "x-kbn-context": "%7B%22type%22%3A%22test-type%22%2C%22name%22%3A%22test-name%22%2C%22id%22%3A%2242%22%2C%22description%22%3A%22test-descripton%22%2C%22parent%22%3A%7B%22type%22%3A%22parent-type%22%2C%22name%22%3A%22parent-name%22%2C%22id%22%3A%2241%22%2C%22description%22%3A%22parent-descripton%22%7D%7D", + "x-kbn-context": "%7B%22type%22%3A%22type%22%2C%22name%22%3A%22name%22%2C%22id%22%3A%2241%22%2C%22description%22%3A%22descripton%22%2C%22child%22%3A%7B%22type%22%3A%22child-test-type%22%2C%22name%22%3A%22child-test-name%22%2C%22id%22%3A%2242%22%2C%22description%22%3A%22child-test-descripton%22%7D%7D", } `); }); @@ -103,35 +103,35 @@ describe('KibanaExecutionContext', () => { }); it('returns JSON representation when the parent context if provided', () => { - const parentAContext: KibanaExecutionContext = { - type: 'parent-a-type', - name: 'parent-a-name', - id: '40', - description: 'parent-a-descripton', + const childBContext: KibanaExecutionContext = { + type: 'child-b-type', + name: 'child-b-name', + id: '42', + description: 'child-b-descripton', }; - const parentBContext: KibanaExecutionContext = { - type: 'parent-b-type', - name: 'parent-b-name', + const childAContext: KibanaExecutionContext = { + type: 'child-a-type', + name: 'child-a-name', id: '41', - description: 'parent-b-descripton', - parent: parentAContext, + description: 'child-a-descripton', + child: childBContext, }; const context: KibanaExecutionContext = { - type: 'test-type', - name: 'test-name', - id: '42', - description: 'test-descripton', - parent: parentBContext, + type: 'type', + name: 'name', + id: '40', + description: 'descripton', + child: childAContext, }; const value = new ExecutionContextContainer(context).toJSON(); expect(value).toEqual({ ...context, - parent: { - ...parentBContext, - parent: parentAContext, + child: { + ...childAContext, + child: childBContext, }, }); }); diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index c610c98c53646..4cf845de4617d 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -754,9 +754,9 @@ export type KibanaExecutionContext = { readonly type: string; readonly name: string; readonly id: string; - readonly description: string; + readonly description?: string; readonly url?: string; - parent?: KibanaExecutionContext; + child?: KibanaExecutionContext; }; // @public diff --git a/src/core/server/execution_context/execution_context_container.test.ts b/src/core/server/execution_context/execution_context_container.test.ts index c332913b2f401..8e9f46ee78a68 100644 --- a/src/core/server/execution_context/execution_context_container.test.ts +++ b/src/core/server/execution_context/execution_context_container.test.ts @@ -16,11 +16,24 @@ import { describe('KibanaExecutionContext', () => { describe('constructor', () => { - it('allows context to define parent explicitly', () => { + it('allows context be defined without a parent', () => { const parentContext: KibanaExecutionContext = { - type: 'parent-type', - name: 'parent-name', - id: '44', + type: 'test-type', + name: 'test-name', + id: '42', + description: 'parent-descripton', + }; + const container = new ExecutionContextContainer(parentContext); + + const value = container.toJSON(); + expect(value.child).toBeUndefined(); + }); + + it('allows context to be called with parent explicitly', () => { + const parentContext: KibanaExecutionContext = { + type: 'test-type', + name: 'test-name', + id: '42', description: 'parent-descripton', }; const parentContainer = new ExecutionContextContainer(parentContext); @@ -30,16 +43,17 @@ describe('KibanaExecutionContext', () => { name: 'test-name', id: '42', description: 'test-descripton', - parent: { - type: 'custom-parent-type', - name: 'custom-parent-name', + child: { + type: 'custom-child-type', + name: 'custom-child-name', id: '41', - description: 'custom-parent-descripton', + description: 'custom-child-descripton', }, }; const value = new ExecutionContextContainer(context, parentContainer).toJSON(); - expect(value).toEqual(context); + expect(value.id).toEqual(parentContext.id); + expect(value.child).toEqual(context); }); }); @@ -56,24 +70,25 @@ describe('KibanaExecutionContext', () => { expect(value).toBe('test-type:test-name:42'); }); - it('includes a parent context to string representation', () => { - const parentContext: KibanaExecutionContext = { - type: 'parent-type', - name: 'parent-name', + it('includes a child context to string representation', () => { + const context: KibanaExecutionContext = { + type: 'type', + name: 'name', id: '41', - description: 'parent-descripton', + description: 'descripton', }; - const parentContainer = new ExecutionContextContainer(parentContext); - const context: KibanaExecutionContext = { - type: 'test-type', - name: 'test-name', + const childContext: KibanaExecutionContext = { + type: 'child-test-type', + name: 'child-test-name', id: '42', description: 'test-descripton', }; - const value = new ExecutionContextContainer(context, parentContainer).toString(); - expect(value).toBe('parent-type:parent-name:41;test-type:test-name:42'); + const contextContainer = new ExecutionContextContainer(context); + + const value = new ExecutionContextContainer(childContext, contextContainer).toString(); + expect(value).toBe('type:name:41;child-test-type:child-test-name:42'); }); it('returns an escaped string representation of provided execution contextStringified', () => { @@ -115,24 +130,24 @@ describe('KibanaExecutionContext', () => { expect(value).toEqual(context); }); - it('returns a context object with registered parent object', () => { - const parentContext: KibanaExecutionContext = { - type: 'parent-type', - name: 'parent-name', + it('returns a context object with registered context object', () => { + const context: KibanaExecutionContext = { + type: 'type', + name: 'name', id: '41', - description: 'parent-descripton', + description: 'descripton', }; - const parentContainer = new ExecutionContextContainer(parentContext); - const context: KibanaExecutionContext = { - type: 'test-type', - name: 'test-name', + const childContext: KibanaExecutionContext = { + type: 'child-test-type', + name: 'child-test-name', id: '42', description: 'test-descripton', }; + const contextContainer = new ExecutionContextContainer(context); - const value = new ExecutionContextContainer(context, parentContainer).toJSON(); - expect(value).toEqual({ ...context, parent: parentContext }); + const value = new ExecutionContextContainer(childContext, contextContainer).toJSON(); + expect(value).toEqual({ child: childContext, ...context }); }); }); }); diff --git a/src/core/server/execution_context/execution_context_container.ts b/src/core/server/execution_context/execution_context_container.ts index a81c409ab3e9e..066248a26ad7b 100644 --- a/src/core/server/execution_context/execution_context_container.ts +++ b/src/core/server/execution_context/execution_context_container.ts @@ -50,14 +50,14 @@ export interface IExecutionContextContainer { } function stringify(ctx: KibanaExecutionContext): string { - const stringifiedCtx = `${ctx.type}:${ctx.name}:${encodeURIComponent(ctx.id)}`; - return ctx.parent ? `${stringify(ctx.parent)};${stringifiedCtx}` : stringifiedCtx; + const stringifiedCtx = `${ctx.type}:${ctx.name}:${encodeURIComponent(ctx.id!)}`; + return ctx.child ? `${stringifiedCtx};${stringify(ctx.child)}` : stringifiedCtx; } export class ExecutionContextContainer implements IExecutionContextContainer { readonly #context: Readonly; constructor(context: KibanaExecutionContext, parent?: IExecutionContextContainer) { - this.#context = { parent: parent?.toJSON(), ...context }; + this.#context = parent ? { ...parent.toJSON(), child: context } : context; } toString(): string { return enforceMaxLength(stringify(this.#context)); diff --git a/src/core/server/execution_context/execution_context_service.test.ts b/src/core/server/execution_context/execution_context_service.test.ts index 9bb76ad78c49a..c39769133cede 100644 --- a/src/core/server/execution_context/execution_context_service.test.ts +++ b/src/core/server/execution_context/execution_context_service.test.ts @@ -59,7 +59,7 @@ describe('ExecutionContextService', () => { name: 'name-a', id: 'id-a', description: 'description-a', - parent: undefined, + child: undefined, }, { @@ -67,7 +67,7 @@ describe('ExecutionContextService', () => { name: 'name-b', id: 'id-b', description: 'description-b', - parent: undefined, + child: undefined, }, ]); }); @@ -271,17 +271,17 @@ describe('ExecutionContextService', () => { ); expect(result?.toJSON()).toEqual({ - type: 'type-b', - name: 'name-b', - id: 'id-b', - description: 'description-b', - parent: { - type: 'type-a', - name: 'name-a', - id: 'id-a', - description: 'description-a', - parent: undefined, + child: { + child: undefined, + type: 'type-b', + name: 'name-b', + id: 'id-b', + description: 'description-b', }, + type: 'type-a', + name: 'name-a', + id: 'id-a', + description: 'description-a', }); }); @@ -306,16 +306,16 @@ describe('ExecutionContextService', () => { ); expect(result?.toJSON()).toEqual({ - type: 'type-b', - name: 'name-b', - id: 'id-b', - description: 'description-b', - parent: { - type: 'type-a', - name: 'name-a', - id: 'id-a', - description: 'description-a', - parent: undefined, + type: 'type-a', + name: 'name-a', + id: 'id-a', + description: 'description-a', + child: { + child: undefined, + type: 'type-b', + name: 'name-b', + id: 'id-b', + description: 'description-b', }, }); }); diff --git a/src/core/server/execution_context/integration_tests/tracing.test.ts b/src/core/server/execution_context/integration_tests/tracing.test.ts index 4aef2e815fa30..c4fc88dd04dc9 100644 --- a/src/core/server/execution_context/integration_tests/tracing.test.ts +++ b/src/core/server/execution_context/integration_tests/tracing.test.ts @@ -589,7 +589,7 @@ describe('trace', () => { expect(response.body).toEqual(parentContext); }); - it('set execution context inerits a parent if presented', async () => { + it('set execution context becomes child if parent context is presented', async () => { const { executionContext, http } = await root.setup(); const { createRouter } = http; @@ -612,7 +612,7 @@ describe('trace', () => { await root.start(); const response = await kbnTestServer.request.get(root, '/execution-context').expect(200); - expect(response.body).toEqual({ ...nestedContext, parent: parentContext }); + expect(response.body).toEqual({ child: nestedContext, ...parentContext }); }); it('extends the execution context passed from the client-side', async () => { diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 111f2ed0001fc..d7ed4928e1cf5 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1322,9 +1322,9 @@ export type KibanaExecutionContext = { readonly type: string; readonly name: string; readonly id: string; - readonly description: string; + readonly description?: string; readonly url?: string; - parent?: KibanaExecutionContext; + child?: KibanaExecutionContext; }; // @public diff --git a/src/core/types/execution_context.ts b/src/core/types/execution_context.ts index 8a2d657812da8..1b985a73f410b 100644 --- a/src/core/types/execution_context.ts +++ b/src/core/types/execution_context.ts @@ -22,9 +22,9 @@ export type KibanaExecutionContext = { /** unique value to identify the source */ readonly id: string; /** human readable description. For example, a vis title, action name */ - readonly description: string; + readonly description?: string; /** in browser - url to navigate to a current page, on server - endpoint path, for task: task SO url */ readonly url?: string; - /** a context that spawned the current context. */ - parent?: KibanaExecutionContext; + /** an inner context spawned from the current context. */ + child?: KibanaExecutionContext; }; diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index b950e42fb5f22..921ed32c0f159 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -12,6 +12,7 @@ import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { isEqual } from 'lodash'; import { I18nProvider } from '@kbn/i18n-react'; +import type { KibanaExecutionContext } from 'kibana/public'; import { Container, Embeddable } from '../../../embeddable/public'; import { ISearchEmbeddable, SearchInput, SearchOutput } from './types'; import { SavedSearch } from '../services/saved_searches'; @@ -168,14 +169,21 @@ export class SavedSearchEmbeddable this.searchProps!.isLoading = true; this.updateOutput({ loading: true, error: undefined }); - const executionContext = { + + const parentContext = this.input.executionContext; + const child: KibanaExecutionContext = { type: this.type, name: 'discover', id: this.savedSearch.id!, description: this.output.title || this.output.defaultTitle || '', url: this.output.editUrl, - parent: this.input.executionContext, }; + const executionContext = parentContext + ? { + ...parentContext, + child, + } + : child; try { // Make the request diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx index a12195e34a81e..24b451533532f 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx @@ -13,6 +13,7 @@ import React from 'react'; import { render } from 'react-dom'; import { EuiLoadingChart } from '@elastic/eui'; import { Filter, onlyDisabledFiltersChanged } from '@kbn/es-query'; +import type { SavedObjectAttributes, KibanaExecutionContext } from 'kibana/public'; import { KibanaThemeProvider } from '../../../kibana_react/public'; import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; import { @@ -41,7 +42,6 @@ import { Vis, SerializedVis } from '../vis'; import { getExpressions, getTheme, getUiActions } from '../services'; import { VIS_EVENT_TO_TRIGGER } from './events'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; -import { SavedObjectAttributes } from '../../../../core/types'; import { getSavedVisualization } from '../utils/saved_visualize_utils'; import { VisSavedObject } from '../types'; import { toExpressionAst } from './to_ast'; @@ -398,14 +398,20 @@ export class VisualizeEmbeddable }; private async updateHandler() { - const context = { + const parentContext = this.parent?.getInput().executionContext; + const child: KibanaExecutionContext = { type: 'visualization', name: this.vis.type.title, id: this.vis.id ?? 'an_unsaved_vis', description: this.vis.title || this.input.title || this.vis.type.name, url: this.output.editUrl, - parent: this.parent?.getInput().executionContext, }; + const context = parentContext + ? { + ...parentContext, + child, + } + : child; const expressionParams: IExpressionLoaderParams = { searchContext: { diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index ca37580ad682f..712e9f9f7f476 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -60,7 +60,11 @@ import { import { IndexPatternsContract } from '../../../../../src/plugins/data/public'; import { getEditPath, DOC_TYPE, PLUGIN_ID } from '../../common'; -import { IBasePath, ThemeServiceStart } from '../../../../../src/core/public'; +import type { + IBasePath, + KibanaExecutionContext, + ThemeServiceStart, +} from '../../../../../src/core/public'; import { LensAttributeService } from '../lens_attribute_service'; import type { ErrorMessage } from '../editor_frame_service/types'; import { getLensInspectorService, LensInspector } from '../lens_inspector_service'; @@ -413,14 +417,20 @@ export class Embeddable this.renderComplete.dispatchInProgress(); - const executionContext = { + const parentContext = this.input.executionContext; + const child: KibanaExecutionContext = { type: 'lens', name: this.savedVis.visualizationType ?? '', id: this.id, description: this.savedVis.title || this.input.title || '', url: this.output.editUrl, - parent: this.input.executionContext, }; + const executionContext = parentContext + ? { + ...parentContext, + child, + } + : child; const input = this.getInput(); diff --git a/x-pack/test/functional_execution_context/tests/browser.ts b/x-pack/test/functional_execution_context/tests/browser.ts index f6e46a6bc2280..ca777e1d4acf3 100644 --- a/x-pack/test/functional_execution_context/tests/browser.ts +++ b/x-pack/test/functional_execution_context/tests/browser.ts @@ -12,8 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'home']); const retry = getService('retry'); - // Failing: See https://github.com/elastic/kibana/issues/112102 - describe.skip('Browser apps', () => { + describe('Browser apps', () => { before(async () => { await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { useActualUrl: true, @@ -98,18 +97,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'lens', + name: 'lnsXY', + id: '086ac2e9-dd16-4b45-92b8-1e43ff7e3f65', + description: '[Flights] Flight count', + url: '/app/lens#/edit_by_value', }, - type: 'lens', - name: 'lnsXY', - id: '086ac2e9-dd16-4b45-92b8-1e43ff7e3f65', - description: '[Flights] Flight count', - url: '/app/lens#/edit_by_value', }), retry, }); @@ -131,18 +130,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'lens', + name: 'lnsMetric', + id: '2e33ade5-96e5-40b4-b460-493e5d4fa834', + description: '', + url: '/app/lens#/edit_by_value', }, - type: 'lens', - name: 'lnsMetric', - id: '2e33ade5-96e5-40b4-b460-493e5d4fa834', - description: '', - url: '/app/lens#/edit_by_value', }), retry, }); @@ -164,18 +163,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'lens', + name: 'lnsDatatable', + id: 'fb86b32f-fb7a-45cf-9511-f366fef51bbd', + description: 'Cities by delay, cancellation', + url: '/app/lens#/edit_by_value', }, - type: 'lens', - name: 'lnsDatatable', - id: 'fb86b32f-fb7a-45cf-9511-f366fef51bbd', - description: 'Cities by delay, cancellation', - url: '/app/lens#/edit_by_value', }), retry, }); @@ -196,18 +195,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'lens', + name: 'lnsPie', + id: '5d53db36-2d5a-4adc-af7b-cec4c1a294e0', + description: '[Flights] Delay Type', + url: '/app/lens#/edit_by_value', }, - type: 'lens', - name: 'lnsPie', - id: '5d53db36-2d5a-4adc-af7b-cec4c1a294e0', - description: '[Flights] Delay Type', - url: '/app/lens#/edit_by_value', }), retry, }); @@ -229,18 +228,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'search', + name: 'discover', + id: '571aaf70-4c88-11e8-b3d7-01146121b73d', + description: '[Flights] Flight Log', + url: '/app/discover#/view/571aaf70-4c88-11e8-b3d7-01146121b73d', }, - type: 'search', - name: 'discover', - id: '571aaf70-4c88-11e8-b3d7-01146121b73d', - description: '[Flights] Flight Log', - url: '/app/discover#/view/571aaf70-4c88-11e8-b3d7-01146121b73d', }), retry, }); @@ -262,18 +261,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'visualization', + name: 'TSVB', + id: 'bcb63b50-4c89-11e8-b3d7-01146121b73d', + description: '[Flights] Delays & Cancellations', + url: '/app/visualize#/edit/bcb63b50-4c89-11e8-b3d7-01146121b73d', }, - type: 'visualization', - name: 'TSVB', - id: 'bcb63b50-4c89-11e8-b3d7-01146121b73d', - description: '[Flights] Delays & Cancellations', - url: '/app/visualize#/edit/bcb63b50-4c89-11e8-b3d7-01146121b73d', }), retry, }); @@ -295,18 +294,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'visualization', + name: 'Vega', + id: 'ed78a660-53a0-11e8-acbd-0be0ad9d822b', + description: '[Flights] Airport Connections (Hover Over Airport)', + url: '/app/visualize#/edit/ed78a660-53a0-11e8-acbd-0be0ad9d822b', }, - type: 'visualization', - name: 'Vega', - id: 'ed78a660-53a0-11e8-acbd-0be0ad9d822b', - description: '[Flights] Airport Connections (Hover Over Airport)', - url: '/app/visualize#/edit/ed78a660-53a0-11e8-acbd-0be0ad9d822b', }), retry, }); @@ -328,18 +327,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'visualization', + name: 'Tag cloud', + id: '293b5a30-4c8f-11e8-b3d7-01146121b73d', + description: '[Flights] Destination Weather', + url: '/app/visualize#/edit/293b5a30-4c8f-11e8-b3d7-01146121b73d', }, - type: 'visualization', - name: 'Tag cloud', - id: '293b5a30-4c8f-11e8-b3d7-01146121b73d', - description: '[Flights] Destination Weather', - url: '/app/visualize#/edit/293b5a30-4c8f-11e8-b3d7-01146121b73d', }), retry, }); @@ -361,18 +360,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'visualization', + name: 'Vertical bar', + id: '9886b410-4c8b-11e8-b3d7-01146121b73d', + description: '[Flights] Delay Buckets', + url: '/app/visualize#/edit/9886b410-4c8b-11e8-b3d7-01146121b73d', }, - type: 'visualization', - name: 'Vertical bar', - id: '9886b410-4c8b-11e8-b3d7-01146121b73d', - description: '[Flights] Delay Buckets', - url: '/app/visualize#/edit/9886b410-4c8b-11e8-b3d7-01146121b73d', }), retry, }); diff --git a/x-pack/test/functional_execution_context/tests/server.ts b/x-pack/test/functional_execution_context/tests/server.ts index 8997c83f4f696..fd10118a03627 100644 --- a/x-pack/test/functional_execution_context/tests/server.ts +++ b/x-pack/test/functional_execution_context/tests/server.ts @@ -93,17 +93,17 @@ export default function ({ getService }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'task manager', - name: 'run alerting:test.executionContext', - // @ts-expect-error. it accepts strings only - id: ANY, - description: 'run task', + type: 'task manager', + name: 'run alerting:test.executionContext', + // @ts-expect-error. it accepts strings only + id: ANY, + description: 'run task', + child: { + type: 'alert', + name: 'execute test.executionContext', + id: alertId, + description: 'execute [test.executionContext] with name [abc] in [default] namespace', }, - type: 'alert', - name: 'execute test.executionContext', - id: alertId, - description: 'execute [test.executionContext] with name [abc] in [default] namespace', }), retry, }); From 87802301ca104592e6db3698922ccc4ee1ff3103 Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Tue, 15 Feb 2022 15:25:36 +0100 Subject: [PATCH 06/39] [Fleet] extracted hook, clean up unused prop (#125610) * extracted hook, clean up unused prop * fixed tests * added tests for hook --- .../components/search_and_filter_bar.tsx | 5 +- .../sections/agents/agent_list_page/index.tsx | 1 - .../fleet/sections/agents/index.tsx | 24 +------- .../agent_enrollment_flyout.test.mocks.ts | 7 +++ .../agent_enrollment_flyout.test.tsx | 13 ++--- .../agent_policy_selection.tsx | 4 +- .../agent_enrollment_flyout/index.tsx | 22 +------ .../managed_instructions.tsx | 3 +- .../agent_enrollment_flyout/steps.tsx | 4 +- .../agent_enrollment_flyout/types.ts | 8 +-- x-pack/plugins/fleet/public/hooks/index.ts | 27 +++++---- .../use_agent_enrollment_flyout.data.test.ts | 57 +++++++++++++++++++ .../hooks/use_agent_enrollment_flyout_data.ts | 38 +++++++++++++ 13 files changed, 131 insertions(+), 82 deletions(-) create mode 100644 x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout.data.test.ts create mode 100644 x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout_data.ts diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx index 46aafb8a31877..a23bfc8bfbd1a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx @@ -103,10 +103,7 @@ export const SearchAndFilterBar: React.FunctionComponent<{ <> {isEnrollmentFlyoutOpen ? ( - setIsEnrollmentFlyoutOpen(false)} - /> + setIsEnrollmentFlyoutOpen(false)} /> ) : null} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index ed4f435f284b3..8e8091d13a794 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -525,7 +525,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { {enrollmentFlyout.isOpen ? ( p.id === enrollmentFlyout.selectedPolicyId)} onClose={() => setEnrollmentFlyoutState({ isOpen: false })} /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx index f900191930ef3..faaadc5f8eb10 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx @@ -5,21 +5,14 @@ * 2.0. */ -import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { Router, Route, Switch, useHistory } from 'react-router-dom'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPortal } from '@elastic/eui'; import { FLEET_ROUTING_PATHS } from '../../constants'; import { Loading, Error, AgentEnrollmentFlyout } from '../../components'; -import { - useConfig, - useFleetStatus, - useBreadcrumbs, - useAuthz, - useGetSettings, - useGetAgentPolicies, -} from '../../hooks'; +import { useConfig, useFleetStatus, useBreadcrumbs, useAuthz, useGetSettings } from '../../hooks'; import { DefaultLayout, WithoutHeaderLayout } from '../../layouts'; import { AgentListPage } from './agent_list_page'; @@ -33,18 +26,6 @@ export const AgentsApp: React.FunctionComponent = () => { const history = useHistory(); const { agents } = useConfig(); const hasFleetAllPrivileges = useAuthz().fleet.all; - - const agentPoliciesRequest = useGetAgentPolicies({ - page: 1, - perPage: 1000, - full: true, - }); - - const agentPolicies = useMemo( - () => agentPoliciesRequest.data?.items || [], - [agentPoliciesRequest.data] - ); - const fleetStatus = useFleetStatus(); const settings = useGetSettings(); @@ -104,7 +85,6 @@ export const AgentsApp: React.FunctionComponent = () => { setIsEnrollmentFlyoutOpen(false)} /> diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts index acb9b198fdcba..15f6437485925 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts @@ -5,6 +5,13 @@ * 2.0. */ +jest.mock('../../hooks', () => { + return { + ...jest.requireActual('../../hooks'), + useAgentEnrollmentFlyoutData: jest.fn(), + }; +}); + jest.mock('../../hooks/use_request', () => { const module = jest.requireActual('../../hooks/use_request'); return { diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx index b46996ef164bd..b0c9fac454c28 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx @@ -21,9 +21,8 @@ import { sendGetFleetStatus, sendGetOneAgentPolicy, useGetAgents, - useGetAgentPolicies, } from '../../hooks/use_request'; -import { FleetStatusProvider, ConfigContext } from '../../hooks'; +import { FleetStatusProvider, ConfigContext, useAgentEnrollmentFlyoutData } from '../../hooks'; import { useFleetServerInstructions } from '../../applications/fleet/sections/agents/agent_requirements_page/components'; @@ -102,13 +101,13 @@ describe('', () => { data: { items: [{ policy_id: 'fleet-server-policy' }] }, }); - (useGetAgentPolicies as jest.Mock).mockReturnValue?.({ - data: { items: [{ id: 'fleet-server-policy' }] }, + (useAgentEnrollmentFlyoutData as jest.Mock).mockReturnValue?.({ + agentPolicies: [{ id: 'fleet-server-policy' } as AgentPolicy], + refreshAgentPolicies: jest.fn(), }); await act(async () => { testBed = await setup({ - agentPolicies: [{ id: 'fleet-server-policy' } as AgentPolicy], onClose: jest.fn(), }); testBed.component.update(); @@ -132,7 +131,6 @@ describe('', () => { jest.clearAllMocks(); await act(async () => { testBed = await setup({ - agentPolicies: [{ id: 'fleet-server-policy' } as AgentPolicy], agentPolicy: testAgentPolicy, onClose: jest.fn(), }); @@ -173,7 +171,6 @@ describe('', () => { jest.clearAllMocks(); await act(async () => { testBed = await setup({ - agentPolicies: [{ id: 'fleet-server-policy' } as AgentPolicy], onClose: jest.fn(), viewDataStep: { title: 'View Data', children:
}, }); @@ -193,7 +190,6 @@ describe('', () => { jest.clearAllMocks(); await act(async () => { testBed = await setup({ - agentPolicies: [{ id: 'fleet-server-policy' } as AgentPolicy], onClose: jest.fn(), viewDataStep: undefined, }); @@ -224,7 +220,6 @@ describe('', () => { jest.clearAllMocks(); await act(async () => { testBed = await setup({ - agentPolicies: [{ id: 'fleet-server-policy' } as AgentPolicy], agentPolicy: testAgentPolicy, onClose: jest.fn(), }); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx index f8ae02fb5a664..8260692616106 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx @@ -51,10 +51,10 @@ type Props = { ); const resolveAgentId = ( - agentPolicies?: AgentPolicy[], + agentPolicies: AgentPolicy[], selectedAgentPolicyId?: string ): undefined | string => { - if (agentPolicies && agentPolicies.length && !selectedAgentPolicyId) { + if (agentPolicies.length && !selectedAgentPolicyId) { if (agentPolicies.length === 1) { return agentPolicies[0].id; } diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx index 9018f508e93ea..960230820e074 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiFlyout, EuiFlyoutBody, @@ -26,7 +26,7 @@ import { useGetSettings, sendGetOneAgentPolicy, useFleetStatus, - useGetAgentPolicies, + useAgentEnrollmentFlyoutData, } from '../../hooks'; import { FLEET_SERVER_PACKAGE } from '../../constants'; import type { PackagePolicy } from '../../types'; @@ -64,23 +64,7 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ const [policyId, setSelectedPolicyId] = useState(agentPolicy?.id); const [isFleetServerPolicySelected, setIsFleetServerPolicySelected] = useState(false); - // loading the latest agentPolicies for add agent flyout - const { - data: agentPoliciesData, - isLoading: isLoadingAgentPolicies, - resendRequest: refreshAgentPolicies, - } = useGetAgentPolicies({ - page: 1, - perPage: 1000, - full: true, - }); - - const agentPolicies = useMemo(() => { - if (!isLoadingAgentPolicies) { - return agentPoliciesData?.items; - } - return []; - }, [isLoadingAgentPolicies, agentPoliciesData?.items]); + const { agentPolicies, refreshAgentPolicies } = useAgentEnrollmentFlyoutData(); useEffect(() => { async function checkPolicyIsFleetServer() { diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx index 6fac9b889a679..8dd0fccc9adb9 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx @@ -81,8 +81,7 @@ export const ManagedInstructions = React.memo( }); const fleetServers = useMemo(() => { - const policies = agentPolicies; - const fleetServerAgentPolicies: string[] = (policies ?? []) + const fleetServerAgentPolicies: string[] = agentPolicies .filter((pol) => policyHasFleetServer(pol)) .map((pol) => pol.id); return (agents?.items ?? []).filter((agent) => diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx index 5e5f26b7317e4..92c71df1b8f0f 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx @@ -83,7 +83,7 @@ export const AgentPolicySelectionStep = ({ excludeFleetServer, refreshAgentPolicies, }: { - agentPolicies?: AgentPolicy[]; + agentPolicies: AgentPolicy[]; setSelectedPolicyId?: (policyId?: string) => void; selectedApiKeyId?: string; setSelectedAPIKeyId?: (key?: string) => void; @@ -93,7 +93,7 @@ export const AgentPolicySelectionStep = ({ // storing the created agent policy id as the child component is being recreated const [policyId, setPolicyId] = useState(undefined); const regularAgentPolicies = useMemo(() => { - return (agentPolicies ?? []).filter( + return agentPolicies.filter( (policy) => policy && !policy.is_managed && (!excludeFleetServer || !policyHasFleetServer(policy)) ); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts index e5a3d345dba32..d66c1006c4654 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts @@ -15,13 +15,6 @@ export interface BaseProps { */ agentPolicy?: AgentPolicy; - /** - * A selection of policies for the user to choose from, will be ignored if `agentPolicy` has been provided. - * - * If this value is `undefined` a value must be provided for `agentPolicy`. - */ - agentPolicies?: AgentPolicy[]; - /** * There is a step in the agent enrollment process that allows users to see the data from an integration represented in the UI * in some way. This is an area for consumers to render a button and text explaining how data can be viewed. @@ -36,5 +29,6 @@ export interface BaseProps { } export interface InstructionProps extends BaseProps { + agentPolicies: AgentPolicy[]; refreshAgentPolicies: () => void; } diff --git a/x-pack/plugins/fleet/public/hooks/index.ts b/x-pack/plugins/fleet/public/hooks/index.ts index 08befa46adae9..5c995131396b4 100644 --- a/x-pack/plugins/fleet/public/hooks/index.ts +++ b/x-pack/plugins/fleet/public/hooks/index.ts @@ -5,20 +5,18 @@ * 2.0. */ -export { useAuthz } from './use_authz'; -export { useStartServices } from './use_core'; -export { useConfig, ConfigContext } from './use_config'; -export { useKibanaVersion, KibanaVersionContext } from './use_kibana_version'; -export { licenseService, useLicense } from './use_license'; -export { useLink } from './use_link'; -export { useKibanaLink, getHrefToObjectInKibanaApp } from './use_kibana_link'; -export type { UsePackageIconType } from './use_package_icon_type'; -export { usePackageIconType } from './use_package_icon_type'; -export type { Pagination } from './use_pagination'; -export { usePagination, PAGE_SIZE_OPTIONS } from './use_pagination'; -export { useUrlPagination } from './use_url_pagination'; -export { useSorting } from './use_sorting'; -export { useDebounce } from './use_debounce'; +export * from './use_authz'; +export * from './use_core'; +export * from './use_config'; +export * from './use_kibana_version'; +export * from './use_license'; +export * from './use_link'; +export * from './use_kibana_link'; +export * from './use_package_icon_type'; +export * from './use_pagination'; +export * from './use_url_pagination'; +export * from './use_sorting'; +export * from './use_debounce'; export * from './use_request'; export * from './use_input'; export * from './use_url_params'; @@ -28,3 +26,4 @@ export * from './use_intra_app_state'; export * from './use_platform'; export * from './use_agent_policy_refresh'; export * from './use_package_installations'; +export * from './use_agent_enrollment_flyout_data'; diff --git a/x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout.data.test.ts b/x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout.data.test.ts new file mode 100644 index 0000000000000..a7b4137b5be29 --- /dev/null +++ b/x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout.data.test.ts @@ -0,0 +1,57 @@ +/* + * 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 { createFleetTestRendererMock } from '../mock'; + +import { useGetAgentPolicies, useAgentEnrollmentFlyoutData } from '.'; + +jest.mock('./use_request', () => { + return { + ...jest.requireActual('./use_request'), + useGetAgentPolicies: jest.fn(), + }; +}); + +describe('useAgentEnrollmentFlyoutData', () => { + const testRenderer = createFleetTestRendererMock(); + + it('should return empty agentPolicies when http loading', () => { + (useGetAgentPolicies as jest.Mock).mockReturnValue({ data: undefined, isLoading: true }); + const { result } = testRenderer.renderHook(() => useAgentEnrollmentFlyoutData()); + expect(result.current.agentPolicies).toEqual([]); + }); + + it('should return empty agentPolicies when http not loading and no data', () => { + (useGetAgentPolicies as jest.Mock).mockReturnValue({ data: undefined }); + const { result } = testRenderer.renderHook(() => useAgentEnrollmentFlyoutData()); + expect(result.current.agentPolicies).toEqual([]); + }); + + it('should return empty agentPolicies when http not loading and no items', () => { + (useGetAgentPolicies as jest.Mock).mockReturnValue({ data: { items: undefined } }); + const { result } = testRenderer.renderHook(() => useAgentEnrollmentFlyoutData()); + expect(result.current.agentPolicies).toEqual([]); + }); + + it('should return agentPolicies when http not loading', () => { + (useGetAgentPolicies as jest.Mock).mockReturnValue({ data: { items: [{ id: 'policy1' }] } }); + const { result } = testRenderer.renderHook(() => useAgentEnrollmentFlyoutData()); + expect(result.current.agentPolicies).toEqual([{ id: 'policy1' }]); + }); + + it('should resend request when refresh agent policies called', () => { + const resendRequestMock = jest.fn(); + (useGetAgentPolicies as jest.Mock).mockReturnValue({ + data: { items: [{ id: 'policy1' }] }, + isLoading: false, + resendRequest: resendRequestMock, + }); + const { result } = testRenderer.renderHook(() => useAgentEnrollmentFlyoutData()); + result.current.refreshAgentPolicies(); + expect(resendRequestMock).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout_data.ts b/x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout_data.ts new file mode 100644 index 0000000000000..d93afd9ac1349 --- /dev/null +++ b/x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout_data.ts @@ -0,0 +1,38 @@ +/* + * 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 { useMemo } from 'react'; + +import type { AgentPolicy } from '../types'; + +import { useGetAgentPolicies } from './use_request'; + +interface AgentEnrollmentFlyoutData { + agentPolicies: AgentPolicy[]; + refreshAgentPolicies: () => void; +} + +export function useAgentEnrollmentFlyoutData(): AgentEnrollmentFlyoutData { + const { + data: agentPoliciesData, + isLoading: isLoadingAgentPolicies, + resendRequest: refreshAgentPolicies, + } = useGetAgentPolicies({ + page: 1, + perPage: 1000, + full: true, + }); + + const agentPolicies = useMemo(() => { + if (!isLoadingAgentPolicies) { + return agentPoliciesData?.items ?? []; + } + return []; + }, [isLoadingAgentPolicies, agentPoliciesData?.items]); + + return { agentPolicies, refreshAgentPolicies }; +} From 472fe62cbeb556eb58bdc9673c16918ff6ed3cae Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 15 Feb 2022 14:59:04 +0000 Subject: [PATCH 07/39] skip flaky suite (#123253) --- .../test/security_solution_endpoint_api_int/apis/metadata.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index b0aaf71ef3257..a4c83b649af65 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -38,7 +38,8 @@ export default function ({ getService }: FtrProviderContext) { describe('test metadata apis', () => { describe('list endpoints GET route', () => { - describe('with .metrics-endpoint.metadata_united_default index', () => { + // FLAKY: https://github.com/elastic/kibana/issues/123253 + describe.skip('with .metrics-endpoint.metadata_united_default index', () => { const numberOfHostsInFixture = 2; before(async () => { From 928638e395f4d645b36a9ae4ab6afe1995db34ab Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Tue, 15 Feb 2022 16:20:30 +0100 Subject: [PATCH 08/39] [Fleet] Avoid breaking setup when compatible package is not available in registry (#125525) --- .../plugins/fleet/common/openapi/bundled.json | 3 ++ .../plugins/fleet/common/openapi/bundled.yaml | 2 + ...epm@packages@{pkg_name}@{pkg_version}.yaml | 2 + .../fleet/server/routes/epm/handlers.ts | 1 + .../server/services/epm/packages/get.test.ts | 42 +++++++++++++++++++ .../fleet/server/services/epm/packages/get.ts | 18 ++++---- .../server/services/epm/packages/install.ts | 7 +++- .../server/services/epm/registry/index.ts | 23 ++++++++-- .../fleet/server/types/rest_spec/epm.ts | 3 +- .../fleet_api_integration/apis/epm/setup.ts | 34 +++++++++++++++ .../0.1.0/data_stream/test/fields/fields.yml | 16 +++++++ .../0.1.0/data_stream/test/manifest.yml | 9 ++++ .../deprecated/0.1.0/docs/README.md | 3 ++ .../deprecated/0.1.0/manifest.yml | 16 +++++++ 14 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/fields/fields.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/manifest.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/docs/README.md create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/manifest.yml diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index 0be8b335ed549..432e72db05e8c 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -646,6 +646,9 @@ "properties": { "force": { "type": "boolean" + }, + "ignore_constraints": { + "type": "boolean" } } } diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 0659352deb1d9..439f56da63e5e 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -396,6 +396,8 @@ paths: properties: force: type: boolean + ignore_constraints: + type: boolean put: summary: Packages - Update tags: [] diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml index 401237008626b..ef0964b66e045 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml @@ -78,6 +78,8 @@ post: properties: force: type: boolean + ignore_constraints: + type: boolean put: summary: Packages - Update tags: [] diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index 16f2d2e13e18c..9bfcffa04bf35 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -265,6 +265,7 @@ export const installPackageFromRegistryHandler: FleetRequestHandler< esClient, spaceId, force: request.body?.force, + ignoreConstraints: request.body?.ignore_constraints, }); if (!res.error) { const body: InstallPackageResponse = { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts index 76e01ed8b2f27..53b4d341beec2 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts @@ -19,6 +19,8 @@ import * as Registry from '../registry'; import { createAppContextStartContractMock } from '../../../mocks'; import { appContextService } from '../../app_context'; +import { PackageNotFoundError } from '../../../errors'; + import { getPackageInfo, getPackageUsageStats } from './get'; const MockRegistry = Registry as jest.Mocked; @@ -279,5 +281,45 @@ describe('When using EPM `get` services', () => { }); }); }); + + describe('registry fetch errors', () => { + it('throws when a package that is not installed is not available in the registry', async () => { + MockRegistry.fetchFindLatestPackage.mockResolvedValue(undefined); + const soClient = savedObjectsClientMock.create(); + soClient.get.mockRejectedValue(SavedObjectsErrorHelpers.createGenericNotFoundError()); + + await expect( + getPackageInfo({ + savedObjectsClient: soClient, + pkgName: 'my-package', + pkgVersion: '1.0.0', + }) + ).rejects.toThrowError(PackageNotFoundError); + }); + + it('sets the latestVersion to installed version when an installed package is not available in the registry', async () => { + MockRegistry.fetchFindLatestPackage.mockResolvedValue(undefined); + const soClient = savedObjectsClientMock.create(); + soClient.get.mockResolvedValue({ + id: 'my-package', + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + attributes: { + install_status: 'installed', + }, + }); + + await expect( + getPackageInfo({ + savedObjectsClient: soClient, + pkgName: 'my-package', + pkgVersion: '1.0.0', + }) + ).resolves.toMatchObject({ + latestVersion: '1.0.0', + status: 'installed', + }); + }); + }); }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index a7cbea4d6462a..c78f107cce715 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -21,7 +21,7 @@ import type { GetCategoriesRequest, } from '../../../../common/types'; import type { Installation, PackageInfo } from '../../../types'; -import { IngestManagerError } from '../../../errors'; +import { IngestManagerError, PackageNotFoundError } from '../../../errors'; import { appContextService } from '../../'; import * as Registry from '../registry'; import { getEsPackage } from '../archive/storage'; @@ -145,17 +145,17 @@ export async function getPackageInfo(options: { const { savedObjectsClient, pkgName, pkgVersion } = options; const [savedObject, latestPackage] = await Promise.all([ getInstallationObject({ savedObjectsClient, pkgName }), - Registry.fetchFindLatestPackage(pkgName), + Registry.fetchFindLatestPackage(pkgName, { throwIfNotFound: false }), ]); - // If no package version is provided, use the installed version in the response - let responsePkgVersion = pkgVersion || savedObject?.attributes.install_version; - - // If no installed version of the given package exists, default to the latest version of the package - if (!responsePkgVersion) { - responsePkgVersion = latestPackage.version; + if (!savedObject && !latestPackage) { + throw new PackageNotFoundError(`[${pkgName}] package not installed or found in registry`); } + // If no package version is provided, use the installed version in the response, fallback to package from registry + const responsePkgVersion = + pkgVersion ?? savedObject?.attributes.install_version ?? latestPackage!.version; + const getPackageRes = await getPackageFromSource({ pkgName, pkgVersion: responsePkgVersion, @@ -166,7 +166,7 @@ export async function getPackageInfo(options: { // add properties that aren't (or aren't yet) on the package const additions: EpmPackageAdditions = { - latestVersion: latestPackage.version, + latestVersion: latestPackage?.version ?? responsePkgVersion, title: packageInfo.title || nameAsTitle(packageInfo.name), assets: Registry.groupPathsByService(paths || []), removable: true, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index 21f0ae25d6faf..9ffae48cb02d8 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -205,6 +205,7 @@ interface InstallRegistryPackageParams { esClient: ElasticsearchClient; spaceId: string; force?: boolean; + ignoreConstraints?: boolean; } function getTelemetryEvent(pkgName: string, pkgVersion: string): PackageUpdateEvent { @@ -233,6 +234,7 @@ async function installPackageFromRegistry({ esClient, spaceId, force = false, + ignoreConstraints = false, }: InstallRegistryPackageParams): Promise { const logger = appContextService.getLogger(); // TODO: change epm API to /packageName/version so we don't need to do this @@ -249,7 +251,7 @@ async function installPackageFromRegistry({ installType = getInstallType({ pkgVersion, installedPkg }); // get latest package version - const latestPackage = await Registry.fetchFindLatestPackage(pkgName); + const latestPackage = await Registry.fetchFindLatestPackage(pkgName, { ignoreConstraints }); // let the user install if using the force flag or needing to reinstall or install a previous version due to failed update const installOutOfDateVersionOk = @@ -469,7 +471,7 @@ export async function installPackage(args: InstallPackageParams) { const { savedObjectsClient, esClient } = args; if (args.installSource === 'registry') { - const { pkgkey, force, spaceId } = args; + const { pkgkey, force, ignoreConstraints, spaceId } = args; logger.debug(`kicking off install of ${pkgkey} from registry`); const response = installPackageFromRegistry({ savedObjectsClient, @@ -477,6 +479,7 @@ export async function installPackage(args: InstallPackageParams) { esClient, spaceId, force, + ignoreConstraints, }); return response; } else if (args.installSource === 'upload') { diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.ts index 5996ce5404b70..12712905b1d36 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.ts @@ -65,18 +65,33 @@ export async function fetchList(params?: SearchParams): Promise { +// When `throwIfNotFound` is true or undefined, return type will never be undefined. +export async function fetchFindLatestPackage( + packageName: string, + options?: { ignoreConstraints?: boolean; throwIfNotFound?: true } +): Promise; +export async function fetchFindLatestPackage( + packageName: string, + options: { ignoreConstraints?: boolean; throwIfNotFound: false } +): Promise; +export async function fetchFindLatestPackage( + packageName: string, + options?: { ignoreConstraints?: boolean; throwIfNotFound?: boolean } +): Promise { + const { ignoreConstraints = false, throwIfNotFound = true } = options ?? {}; const registryUrl = getRegistryUrl(); const url = new URL(`${registryUrl}/search?package=${packageName}&experimental=true`); - setKibanaVersion(url); + if (!ignoreConstraints) { + setKibanaVersion(url); + } const res = await fetchUrl(url.toString()); const searchResults = JSON.parse(res); if (searchResults.length) { return searchResults[0]; - } else { - throw new PackageNotFoundError(`${packageName} not found`); + } else if (throwIfNotFound) { + throw new PackageNotFoundError(`[${packageName}] package not found in registry`); } } diff --git a/x-pack/plugins/fleet/server/types/rest_spec/epm.ts b/x-pack/plugins/fleet/server/types/rest_spec/epm.ts index 390d5dea792cb..c51a0127c2e29 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/epm.ts @@ -74,7 +74,8 @@ export const InstallPackageFromRegistryRequestSchema = { }), body: schema.nullable( schema.object({ - force: schema.boolean(), + force: schema.boolean({ defaultValue: false }), + ignore_constraints: schema.boolean({ defaultValue: false }), }) ), }; diff --git a/x-pack/test/fleet_api_integration/apis/epm/setup.ts b/x-pack/test/fleet_api_integration/apis/epm/setup.ts index 44e582b445f96..eb29920b83036 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/setup.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/setup.ts @@ -52,6 +52,40 @@ export default function (providerContext: FtrProviderContext) { }); }); + it('does not fail when package is no longer compatible in registry', async () => { + await supertest + .post(`/api/fleet/epm/packages/deprecated/0.1.0`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true, ignore_constraints: true }) + .expect(200); + + const agentPolicyResponse = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'deprecated-ap-1', + namespace: 'default', + monitoring_enabled: [], + }) + .expect(200); + + await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'deprecated-1', + policy_id: agentPolicyResponse.body.item.id, + package: { + name: 'deprecated', + version: '0.1.0', + }, + inputs: [], + }) + .expect(200); + + await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'xxxx').expect(200); + }); + it('allows elastic/fleet-server user to call required APIs', async () => { const { token, diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/fields/fields.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/fields/fields.yml new file mode 100644 index 0000000000000..6e003ed0ad147 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/fields/fields.yml @@ -0,0 +1,16 @@ +- name: data_stream.type + type: constant_keyword + description: > + Data stream type. +- name: data_stream.dataset + type: constant_keyword + description: > + Data stream dataset. +- name: data_stream.namespace + type: constant_keyword + description: > + Data stream namespace. +- name: '@timestamp' + type: date + description: > + Event timestamp. diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/manifest.yml new file mode 100644 index 0000000000000..9ac3c68a0be9e --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/manifest.yml @@ -0,0 +1,9 @@ +title: Test Dataset + +type: logs + +elasticsearch: + index_template.mappings: + dynamic: false + index_template.settings: + index.lifecycle.name: reference diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/docs/README.md b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/docs/README.md new file mode 100644 index 0000000000000..13ef3f4fa9152 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/docs/README.md @@ -0,0 +1,3 @@ +# Test package + +This is a test package for testing installing or updating to an out-of-date package \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/manifest.yml new file mode 100644 index 0000000000000..755c49e1af388 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/manifest.yml @@ -0,0 +1,16 @@ +format_version: 1.0.0 +name: deprecated +title: Package install/update test +description: This is a package for testing deprecated packages +version: 0.1.0 +categories: [] +release: beta +type: integration +license: basic + +conditions: + # Version number is not compatible with current version + elasticsearch: + version: '^1.0.0' + kibana: + version: '^1.0.0' From 21cef490f3ed3b91ae5f41e4cc38070cd0dd0d58 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Tue, 15 Feb 2022 09:25:20 -0600 Subject: [PATCH 09/39] [kibana_react] Enable Storybook for all of kibana_react (#125589) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../steps/storybooks/build_and_upload.js | 3 +-- src/dev/storybook/aliases.ts | 3 +-- .../.storybook/main.js => .storybook/main.ts} | 5 +++-- .../kibana_react/.storybook/manager.ts | 21 +++++++++++++++++++ .../kibana_react/public/code_editor/README.md | 2 +- .../url_template_editor/.storybook/main.js | 10 --------- src/plugins/kibana_react/tsconfig.json | 2 +- test/scripts/jenkins_storybook.sh | 10 ++++----- 8 files changed, 33 insertions(+), 23 deletions(-) rename src/plugins/kibana_react/{public/code_editor/.storybook/main.js => .storybook/main.ts} (77%) create mode 100644 src/plugins/kibana_react/.storybook/manager.ts delete mode 100644 src/plugins/kibana_react/public/url_template_editor/.storybook/main.js diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.js b/.buildkite/scripts/steps/storybooks/build_and_upload.js index 0af75e72de78a..9d40edc905763 100644 --- a/.buildkite/scripts/steps/storybooks/build_and_upload.js +++ b/.buildkite/scripts/steps/storybooks/build_and_upload.js @@ -16,7 +16,6 @@ const STORYBOOKS = [ 'canvas', 'ci_composite', 'cloud', - 'codeeditor', 'custom_integrations', 'dashboard_enhanced', 'dashboard', @@ -31,13 +30,13 @@ const STORYBOOKS = [ 'expression_tagcloud', 'fleet', 'infra', + 'kibana_react', 'lists', 'observability', 'presentation', 'security_solution', 'shared_ux', 'ui_actions_enhanced', - 'url_template_editor', ]; const GITHUB_CONTEXT = 'Build and Publish Storybooks'; diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 542acf7b0fa8f..db0791f41b0a7 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -12,7 +12,6 @@ export const storybookAliases = { canvas: 'x-pack/plugins/canvas/storybook', ci_composite: '.ci/.storybook', cloud: 'x-pack/plugins/cloud/.storybook', - codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook', controls: 'src/plugins/controls/storybook', custom_integrations: 'src/plugins/custom_integrations/storybook', dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook', @@ -31,11 +30,11 @@ export const storybookAliases = { expression_tagcloud: 'src/plugins/chart_expressions/expression_tagcloud/.storybook', fleet: 'x-pack/plugins/fleet/.storybook', infra: 'x-pack/plugins/infra/.storybook', + kibana_react: 'src/plugins/kibana_react/.storybook', lists: 'x-pack/plugins/lists/.storybook', observability: 'x-pack/plugins/observability/.storybook', presentation: 'src/plugins/presentation_util/storybook', security_solution: 'x-pack/plugins/security_solution/.storybook', shared_ux: 'src/plugins/shared_ux/.storybook', ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/.storybook', - url_template_editor: 'src/plugins/kibana_react/public/url_template_editor/.storybook', }; diff --git a/src/plugins/kibana_react/public/code_editor/.storybook/main.js b/src/plugins/kibana_react/.storybook/main.ts similarity index 77% rename from src/plugins/kibana_react/public/code_editor/.storybook/main.js rename to src/plugins/kibana_react/.storybook/main.ts index 742239e638b8a..1261fe5a06f69 100644 --- a/src/plugins/kibana_react/public/code_editor/.storybook/main.js +++ b/src/plugins/kibana_react/.storybook/main.ts @@ -6,5 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-commonjs -module.exports = require('@kbn/storybook').defaultConfig; +import { defaultConfig } from '@kbn/storybook'; + +module.exports = defaultConfig; diff --git a/src/plugins/kibana_react/.storybook/manager.ts b/src/plugins/kibana_react/.storybook/manager.ts new file mode 100644 index 0000000000000..27eaef2b2be0e --- /dev/null +++ b/src/plugins/kibana_react/.storybook/manager.ts @@ -0,0 +1,21 @@ +/* + * 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 { addons } from '@storybook/addons'; +import { create } from '@storybook/theming'; +import { PANEL_ID } from '@storybook/addon-actions'; + +addons.setConfig({ + theme: create({ + base: 'light', + brandTitle: 'Kibana React Storybook', + brandUrl: 'https://github.com/elastic/kibana/tree/main/src/plugins/kibana_react', + }), + showPanel: true.valueOf, + selectedPanel: PANEL_ID, +}); diff --git a/src/plugins/kibana_react/public/code_editor/README.md b/src/plugins/kibana_react/public/code_editor/README.md index 811038b58c828..df8913fb32f96 100644 --- a/src/plugins/kibana_react/public/code_editor/README.md +++ b/src/plugins/kibana_react/public/code_editor/README.md @@ -11,6 +11,6 @@ This editor component allows easy access to: The Monaco editor doesn't automatically resize the editor area on window or container resize so this component includes a [resize detector](https://github.com/maslianok/react-resize-detector) to cause the Monaco editor to re-layout and adjust its size when the window or container size changes ## Storybook Examples -To run the CodeEditor storybook, from the root kibana directory, run `yarn storybook codeeditor` +To run the `CodeEditor` Storybook, from the root kibana directory, run `yarn storybook kibana_react` All stories for the component live in `code_editor.examples.tsx` \ No newline at end of file diff --git a/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js b/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js deleted file mode 100644 index 742239e638b8a..0000000000000 --- a/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js +++ /dev/null @@ -1,10 +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. - */ - -// eslint-disable-next-line import/no-commonjs -module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/plugins/kibana_react/tsconfig.json b/src/plugins/kibana_react/tsconfig.json index 3f6dd8fd280b6..43b51a45e08c4 100644 --- a/src/plugins/kibana_react/tsconfig.json +++ b/src/plugins/kibana_react/tsconfig.json @@ -6,6 +6,6 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*", "../../../typings/**/*"], + "include": [".storybook/**/*", "common/**/*", "public/**/*", "../../../typings/**/*"], "references": [{ "path": "../kibana_utils/tsconfig.json" }] } diff --git a/test/scripts/jenkins_storybook.sh b/test/scripts/jenkins_storybook.sh index bf8b881a91ecd..e03494e13677d 100755 --- a/test/scripts/jenkins_storybook.sh +++ b/test/scripts/jenkins_storybook.sh @@ -6,10 +6,8 @@ cd "$KIBANA_DIR" yarn storybook --site apm yarn storybook --site canvas -yarn storybook --site codeeditor yarn storybook --site ci_composite yarn storybook --site custom_integrations -yarn storybook --site url_template_editor yarn storybook --site dashboard yarn storybook --site dashboard_enhanced yarn storybook --site data_enhanced @@ -23,8 +21,10 @@ yarn storybook --site expression_shape yarn storybook --site expression_tagcloud yarn storybook --site fleet yarn storybook --site infra -yarn storybook --site security_solution -yarn storybook --site ui_actions_enhanced +yarn storybook --site kibana_react +yarn storybook --site lists yarn storybook --site observability yarn storybook --site presentation -yarn storybook --site lists +yarn storybook --site security_solution +yarn storybook --site shared_ux +yarn storybook --site ui_actions_enhanced From c2a010367dabb7923c094291d94d040aa74cdbf6 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Tue, 15 Feb 2022 10:31:48 -0500 Subject: [PATCH 10/39] [Task Manager] Adding list of explicitly de-registered task types (#123963) * Adding REMOVED_TYPES to task manager and only marking those types as unrecognized * Adding unit tests * Fixing functional test * Throwing error when registering a removed task type * Adding migration * Adding functional tests * Cleanup * Adding disabled siem signals rule type Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/task_manager/server/plugin.ts | 3 +- .../server/polling_lifecycle.test.ts | 1 + .../task_manager/server/polling_lifecycle.ts | 3 + .../mark_available_tasks_as_claimed.test.ts | 47 ++++--- .../mark_available_tasks_as_claimed.ts | 27 ++-- .../server/queries/task_claiming.test.ts | 125 ++++++++++++++++++ .../server/queries/task_claiming.ts | 22 +-- .../server/saved_objects/migrations.test.ts | 56 ++++++++ .../server/saved_objects/migrations.ts | 26 +++- .../server/task_type_dictionary.test.ts | 58 +++++++- .../server/task_type_dictionary.ts | 16 +++ .../task_manager_removed_types/data.json | 31 +++++ .../es_archives/task_manager_tasks/data.json | 62 +++++++++ .../test_suites/task_manager/migrations.ts | 32 +++++ .../task_management_removed_types.ts | 12 +- 15 files changed, 483 insertions(+), 38 deletions(-) diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index bb4c461758f96..b58b0665c10c0 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -22,7 +22,7 @@ import { TaskManagerConfig } from './config'; import { createInitialMiddleware, addMiddlewareToChain, Middleware } from './lib/middleware'; import { removeIfExists } from './lib/remove_if_exists'; import { setupSavedObjects } from './saved_objects'; -import { TaskDefinitionRegistry, TaskTypeDictionary } from './task_type_dictionary'; +import { TaskDefinitionRegistry, TaskTypeDictionary, REMOVED_TYPES } from './task_type_dictionary'; import { FetchResult, SearchOpts, TaskStore } from './task_store'; import { createManagedConfiguration } from './lib/create_managed_configuration'; import { TaskScheduling } from './task_scheduling'; @@ -189,6 +189,7 @@ export class TaskManagerPlugin this.taskPollingLifecycle = new TaskPollingLifecycle({ config: this.config!, definitions: this.definitions, + unusedTypes: REMOVED_TYPES, logger: this.logger, executionContext, taskStore, diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts index b6a93b14f578b..cf29d1f475c6c 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts @@ -70,6 +70,7 @@ describe('TaskPollingLifecycle', () => { }, taskStore: mockTaskStore, logger: taskManagerLogger, + unusedTypes: [], definitions: new TaskTypeDictionary(taskManagerLogger), middleware: createInitialMiddleware(), maxWorkersConfiguration$: of(100), diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.ts index b61891d732f5e..a452c8a3f82fb 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.ts @@ -50,6 +50,7 @@ import { TaskClaiming, ClaimOwnershipResult } from './queries/task_claiming'; export type TaskPollingLifecycleOpts = { logger: Logger; definitions: TaskTypeDictionary; + unusedTypes: string[]; taskStore: TaskStore; config: TaskManagerConfig; middleware: Middleware; @@ -106,6 +107,7 @@ export class TaskPollingLifecycle { config, taskStore, definitions, + unusedTypes, executionContext, usageCounter, }: TaskPollingLifecycleOpts) { @@ -134,6 +136,7 @@ export class TaskPollingLifecycle { maxAttempts: config.max_attempts, excludedTaskTypes: config.unsafe.exclude_task_types, definitions, + unusedTypes, logger: this.logger, getCapacity: (taskType?: string) => taskType && this.definitions.get(taskType)?.maxConcurrency diff --git a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts index 9e31ab9f0cb4e..18ed1a5802538 100644 --- a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts +++ b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts @@ -47,15 +47,16 @@ describe('mark_available_tasks_as_claimed', () => { // status running or claiming with a retryAt <= now. shouldBeOneOf(IdleTaskWithExpiredRunAt, RunningOrClaimingTaskWithExpiredRetryAt) ), - script: updateFieldsAndMarkAsFailed( + script: updateFieldsAndMarkAsFailed({ fieldUpdates, - claimTasksById || [], - definitions.getAllTypes(), - [], - Array.from(definitions).reduce((accumulator, [type, { maxAttempts }]) => { + claimTasksById: claimTasksById || [], + claimableTaskTypes: definitions.getAllTypes(), + skippedTaskTypes: [], + unusedTaskTypes: [], + taskMaxAttempts: Array.from(definitions).reduce((accumulator, [type, { maxAttempts }]) => { return { ...accumulator, [type]: maxAttempts || defaultMaxAttempts }; - }, {}) - ), + }, {}), + }), sort: SortByRunAtAndRetryAt, }).toEqual({ query: { @@ -126,7 +127,7 @@ if (doc['task.runAt'].size()!=0) { ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) .join(' ')} - } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + } else if (params.unusedTaskTypes.contains(ctx._source.task.taskType)) { ctx._source.task.status = "unrecognized"; } else { ctx.op = "noop"; @@ -140,6 +141,7 @@ if (doc['task.runAt'].size()!=0) { claimTasksById: [], claimableTaskTypes: ['sampleTask', 'otherTask'], skippedTaskTypes: [], + unusedTaskTypes: [], taskMaxAttempts: { sampleTask: 5, otherTask: 1, @@ -164,9 +166,16 @@ if (doc['task.runAt'].size()!=0) { ]; expect( - updateFieldsAndMarkAsFailed(fieldUpdates, claimTasksById, ['foo', 'bar'], [], { - foo: 5, - bar: 2, + updateFieldsAndMarkAsFailed({ + fieldUpdates, + claimTasksById, + claimableTaskTypes: ['foo', 'bar'], + skippedTaskTypes: [], + unusedTaskTypes: [], + taskMaxAttempts: { + foo: 5, + bar: 2, + }, }) ).toMatchObject({ source: ` @@ -182,7 +191,7 @@ if (doc['task.runAt'].size()!=0) { ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) .join(' ')} - } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + } else if (params.unusedTaskTypes.contains(ctx._source.task.taskType)) { ctx._source.task.status = "unrecognized"; } else { ctx.op = "noop"; @@ -196,6 +205,7 @@ if (doc['task.runAt'].size()!=0) { ], claimableTaskTypes: ['foo', 'bar'], skippedTaskTypes: [], + unusedTaskTypes: [], taskMaxAttempts: { foo: 5, bar: 2, @@ -213,9 +223,16 @@ if (doc['task.runAt'].size()!=0) { }; expect( - updateFieldsAndMarkAsFailed(fieldUpdates, [], ['foo', 'bar'], [], { - foo: 5, - bar: 2, + updateFieldsAndMarkAsFailed({ + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['foo', 'bar'], + skippedTaskTypes: [], + unusedTaskTypes: [], + taskMaxAttempts: { + foo: 5, + bar: 2, + }, }).source ).toMatch(/ctx.op = "noop"/); }); diff --git a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts index b1ccb191bdce0..5f2aa25253b0c 100644 --- a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts +++ b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts @@ -104,15 +104,25 @@ if (doc['task.runAt'].size()!=0) { }; export const SortByRunAtAndRetryAt = SortByRunAtAndRetryAtScript as estypes.SortCombinations; -export const updateFieldsAndMarkAsFailed = ( +export interface UpdateFieldsAndMarkAsFailedOpts { fieldUpdates: { [field: string]: string | number | Date; - }, - claimTasksById: string[], - claimableTaskTypes: string[], - skippedTaskTypes: string[], - taskMaxAttempts: { [field: string]: number } -): ScriptClause => { + }; + claimTasksById: string[]; + claimableTaskTypes: string[]; + skippedTaskTypes: string[]; + unusedTaskTypes: string[]; + taskMaxAttempts: { [field: string]: number }; +} + +export const updateFieldsAndMarkAsFailed = ({ + fieldUpdates, + claimTasksById, + claimableTaskTypes, + skippedTaskTypes, + unusedTaskTypes, + taskMaxAttempts, +}: UpdateFieldsAndMarkAsFailedOpts): ScriptClause => { const markAsClaimingScript = `ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) .join(' ')}`; @@ -126,7 +136,7 @@ export const updateFieldsAndMarkAsFailed = ( } } else if (params.skippedTaskTypes.contains(ctx._source.task.taskType) && params.claimTasksById.contains(ctx._id)) { ${markAsClaimingScript} - } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + } else if (params.unusedTaskTypes.contains(ctx._source.task.taskType)) { ctx._source.task.status = "unrecognized"; } else { ctx.op = "noop"; @@ -137,6 +147,7 @@ export const updateFieldsAndMarkAsFailed = ( claimTasksById, claimableTaskTypes, skippedTaskTypes, + unusedTaskTypes, taskMaxAttempts, }, }; diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts index ed656b5144956..7b46f10adaabc 100644 --- a/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts @@ -109,6 +109,7 @@ describe('TaskClaiming', () => { logger: taskManagerLogger, definitions, excludedTaskTypes: [], + unusedTypes: [], taskStore: taskStoreMock.create({ taskManagerId: '' }), maxAttempts: 2, getCapacity: () => 10, @@ -127,12 +128,14 @@ describe('TaskClaiming', () => { hits = [generateFakeTasks(1)], versionConflicts = 2, excludedTaskTypes = [], + unusedTaskTypes = [], }: { storeOpts: Partial; taskClaimingOpts: Partial; hits?: ConcreteTaskInstance[][]; versionConflicts?: number; excludedTaskTypes?: string[]; + unusedTaskTypes?: string[]; }) { const definitions = storeOpts.definitions ?? taskDefinitions; const store = taskStoreMock.create({ taskManagerId: storeOpts.taskManagerId }); @@ -161,6 +164,7 @@ describe('TaskClaiming', () => { definitions, taskStore: store, excludedTaskTypes, + unusedTypes: unusedTaskTypes, maxAttempts: taskClaimingOpts.maxAttempts ?? 2, getCapacity: taskClaimingOpts.getCapacity ?? (() => 10), ...taskClaimingOpts, @@ -176,6 +180,7 @@ describe('TaskClaiming', () => { hits = [generateFakeTasks(1)], versionConflicts = 2, excludedTaskTypes = [], + unusedTaskTypes = [], }: { storeOpts: Partial; taskClaimingOpts: Partial; @@ -183,12 +188,14 @@ describe('TaskClaiming', () => { hits?: ConcreteTaskInstance[][]; versionConflicts?: number; excludedTaskTypes?: string[]; + unusedTaskTypes?: string[]; }) { const getCapacity = taskClaimingOpts.getCapacity ?? (() => 10); const { taskClaiming, store } = initialiseTestClaiming({ storeOpts, taskClaimingOpts, excludedTaskTypes, + unusedTaskTypes, hits, versionConflicts, }); @@ -496,6 +503,7 @@ if (doc['task.runAt'].size()!=0) { ], claimableTaskTypes: ['foo', 'bar'], skippedTaskTypes: [], + unusedTaskTypes: [], taskMaxAttempts: { bar: customMaxAttempts, foo: maxAttempts, @@ -614,6 +622,7 @@ if (doc['task.runAt'].size()!=0) { 'anotherLimitedToOne', 'limitedToTwo', ], + unusedTaskTypes: [], taskMaxAttempts: { unlimited: maxAttempts, }, @@ -871,6 +880,121 @@ if (doc['task.runAt'].size()!=0) { expect(firstCycle).not.toMatchObject(secondCycle); }); + test('it passes any unusedTaskTypes to script', async () => { + const maxAttempts = _.random(2, 43); + const customMaxAttempts = _.random(44, 100); + const taskManagerId = uuid.v1(); + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: new Date(Date.now()), + }; + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + createTaskRunner: jest.fn(), + }, + bar: { + title: 'bar', + maxAttempts: customMaxAttempts, + createTaskRunner: jest.fn(), + }, + foobar: { + title: 'foobar', + maxAttempts: customMaxAttempts, + createTaskRunner: jest.fn(), + }, + }); + + const [ + { + args: { + updateByQuery: [{ query, script }], + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + definitions, + taskManagerId, + }, + taskClaimingOpts: { + maxAttempts, + }, + claimingOpts: { + claimOwnershipUntil: new Date(), + }, + excludedTaskTypes: ['foobar'], + unusedTaskTypes: ['barfoo'], + }); + expect(query).toMatchObject({ + bool: { + must: [ + { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'task.status': 'idle' } }, + { range: { 'task.runAt': { lte: 'now' } } }, + ], + }, + }, + { + bool: { + must: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + }, + }, + { range: { 'task.retryAt': { lte: 'now' } } }, + ], + }, + }, + ], + }, + }, + ], + filter: [ + { + bool: { + must_not: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + must: { range: { 'task.retryAt': { gt: 'now' } } }, + }, + }, + ], + }, + }, + ], + }, + }); + expect(script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['foo', 'bar'], + skippedTaskTypes: ['foobar'], + unusedTaskTypes: ['barfoo'], + taskMaxAttempts: { + bar: customMaxAttempts, + foo: maxAttempts, + }, + }, + }); + }); + test('it claims tasks by setting their ownerId, status and retryAt', async () => { const taskManagerId = uuid.v1(); const claimOwnershipUntil = new Date(Date.now()); @@ -1263,6 +1387,7 @@ if (doc['task.runAt'].size()!=0) { logger: taskManagerLogger, definitions, excludedTaskTypes: [], + unusedTypes: [], taskStore, maxAttempts: 2, getCapacity, diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.ts index b45591a233e19..1b4f0fdb73683 100644 --- a/x-pack/plugins/task_manager/server/queries/task_claiming.ts +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.ts @@ -57,6 +57,7 @@ import { TASK_MANAGER_TRANSACTION_TYPE } from '../task_running'; export interface TaskClaimingOpts { logger: Logger; definitions: TaskTypeDictionary; + unusedTypes: string[]; taskStore: TaskStore; maxAttempts: number; excludedTaskTypes: string[]; @@ -121,6 +122,7 @@ export class TaskClaiming { private readonly taskClaimingBatchesByType: TaskClaimingBatches; private readonly taskMaxAttempts: Record; private readonly excludedTaskTypes: string[]; + private readonly unusedTypes: string[]; /** * Constructs a new TaskStore. @@ -137,6 +139,7 @@ export class TaskClaiming { this.taskClaimingBatchesByType = this.partitionIntoClaimingBatches(this.definitions); this.taskMaxAttempts = Object.fromEntries(this.normalizeMaxAttempts(this.definitions)); this.excludedTaskTypes = opts.excludedTaskTypes; + this.unusedTypes = opts.unusedTypes; this.events$ = new Subject(); } @@ -225,7 +228,7 @@ export class TaskClaiming { return of(accumulatedResult); } return from( - this.executClaimAvailableTasks({ + this.executeClaimAvailableTasks({ claimOwnershipUntil, claimTasksById: claimTasksById.splice(0, capacity), size: capacity, @@ -249,7 +252,7 @@ export class TaskClaiming { ); } - private executClaimAvailableTasks = async ({ + private executeClaimAvailableTasks = async ({ claimOwnershipUntil, claimTasksById = [], size, @@ -403,16 +406,17 @@ export class TaskClaiming { : queryForScheduledTasks, filterDownBy(InactiveTasks) ); - const script = updateFieldsAndMarkAsFailed( - { + const script = updateFieldsAndMarkAsFailed({ + fieldUpdates: { ownerId: this.taskStore.taskManagerId, retryAt: claimOwnershipUntil, }, - claimTasksById || [], - taskTypesToClaim, - taskTypesToSkip, - pick(this.taskMaxAttempts, taskTypesToClaim) - ); + claimTasksById: claimTasksById || [], + claimableTaskTypes: taskTypesToClaim, + skippedTaskTypes: taskTypesToSkip, + unusedTaskTypes: this.unusedTypes, + taskMaxAttempts: pick(this.taskMaxAttempts, taskTypesToClaim), + }); const apmTrans = apm.startTransaction( TASK_MANAGER_MARK_AS_CLAIMED, diff --git a/x-pack/plugins/task_manager/server/saved_objects/migrations.test.ts b/x-pack/plugins/task_manager/server/saved_objects/migrations.test.ts index e912eda258090..cfd0f874f58ff 100644 --- a/x-pack/plugins/task_manager/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/task_manager/server/saved_objects/migrations.test.ts @@ -169,6 +169,62 @@ describe('successful migrations', () => { expect(migration820(taskInstance, migrationContext)).toEqual(taskInstance); }); + + test('resets "unrecognized" status to "idle" when task type is not in REMOVED_TYPES list', () => { + const migration820 = getMigrations()['8.2.0']; + const taskInstance = getMockData({ + taskType: 'someValidTask', + status: 'unrecognized', + }); + + expect(migration820(taskInstance, migrationContext)).toEqual({ + ...taskInstance, + attributes: { + ...taskInstance.attributes, + status: 'idle', + }, + }); + }); + + test('does not modify "unrecognized" status when task type is in REMOVED_TYPES list', () => { + const migration820 = getMigrations()['8.2.0']; + const taskInstance = getMockData({ + taskType: 'sampleTaskRemovedType', + status: 'unrecognized', + }); + + expect(migration820(taskInstance, migrationContext)).toEqual(taskInstance); + }); + + test('does not modify document when status is "running"', () => { + const migration820 = getMigrations()['8.2.0']; + const taskInstance = getMockData({ + taskType: 'someTask', + status: 'running', + }); + + expect(migration820(taskInstance, migrationContext)).toEqual(taskInstance); + }); + + test('does not modify document when status is "idle"', () => { + const migration820 = getMigrations()['8.2.0']; + const taskInstance = getMockData({ + taskType: 'someTask', + status: 'idle', + }); + + expect(migration820(taskInstance, migrationContext)).toEqual(taskInstance); + }); + + test('does not modify document when status is "failed"', () => { + const migration820 = getMigrations()['8.2.0']; + const taskInstance = getMockData({ + taskType: 'someTask', + status: 'failed', + }); + + expect(migration820(taskInstance, migrationContext)).toEqual(taskInstance); + }); }); }); diff --git a/x-pack/plugins/task_manager/server/saved_objects/migrations.ts b/x-pack/plugins/task_manager/server/saved_objects/migrations.ts index f50b3d6a927ad..6e527918f2a7e 100644 --- a/x-pack/plugins/task_manager/server/saved_objects/migrations.ts +++ b/x-pack/plugins/task_manager/server/saved_objects/migrations.ts @@ -13,6 +13,7 @@ import { SavedObjectsUtils, SavedObjectUnsanitizedDoc, } from '../../../../../src/core/server'; +import { REMOVED_TYPES } from '../task_type_dictionary'; import { ConcreteTaskInstance, TaskStatus } from '../task'; interface TaskInstanceLogMeta extends LogMeta { @@ -38,7 +39,7 @@ export function getMigrations(): SavedObjectMigrationMap { '8.0.0' ), '8.2.0': executeMigrationWithErrorHandling( - pipeMigrations(resetAttemptsAndStatusForTheTasksWithoutSchedule), + pipeMigrations(resetAttemptsAndStatusForTheTasksWithoutSchedule, resetUnrecognizedStatus), '8.2.0' ), }; @@ -143,6 +144,29 @@ function moveIntervalIntoSchedule({ }; } +function resetUnrecognizedStatus( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + const status = doc?.attributes?.status; + if (status && status === 'unrecognized') { + const taskType = doc.attributes.taskType; + // If task type is in the REMOVED_TYPES list, maintain "unrecognized" status + if (REMOVED_TYPES.indexOf(taskType) >= 0) { + return doc; + } + + return { + ...doc, + attributes: { + ...doc.attributes, + status: 'idle', + }, + } as SavedObjectUnsanitizedDoc; + } + + return doc; +} + function pipeMigrations(...migrations: TaskInstanceMigration[]): TaskInstanceMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); diff --git a/x-pack/plugins/task_manager/server/task_type_dictionary.test.ts b/x-pack/plugins/task_manager/server/task_type_dictionary.test.ts index d682d40a1d811..cb2f436fa8676 100644 --- a/x-pack/plugins/task_manager/server/task_type_dictionary.test.ts +++ b/x-pack/plugins/task_manager/server/task_type_dictionary.test.ts @@ -7,7 +7,12 @@ import { get } from 'lodash'; import { RunContext, TaskDefinition } from './task'; -import { sanitizeTaskDefinitions, TaskDefinitionRegistry } from './task_type_dictionary'; +import { mockLogger } from './test_utils'; +import { + sanitizeTaskDefinitions, + TaskDefinitionRegistry, + TaskTypeDictionary, +} from './task_type_dictionary'; interface Opts { numTasks: number; @@ -40,6 +45,12 @@ const getMockTaskDefinitions = (opts: Opts) => { }; describe('taskTypeDictionary', () => { + let definitions: TaskTypeDictionary; + + beforeEach(() => { + definitions = new TaskTypeDictionary(mockLogger()); + }); + describe('sanitizeTaskDefinitions', () => {}); it('provides tasks with defaults', () => { const taskDefinitions = getMockTaskDefinitions({ numTasks: 3 }); @@ -154,4 +165,49 @@ describe('taskTypeDictionary', () => { `"Invalid timeout \\"1.5h\\". Timeout must be of the form \\"{number}{cadance}\\" where number is an integer. Example: 5m."` ); }); + + describe('registerTaskDefinitions', () => { + it('registers a valid task', () => { + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + expect(definitions.has('foo')).toBe(true); + }); + + it('throws error when registering duplicate task type', () => { + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + + expect(() => { + definitions.registerTaskDefinitions({ + foo: { + title: 'foo2', + createTaskRunner: jest.fn(), + }, + }); + }).toThrowErrorMatchingInlineSnapshot(`"Task foo is already defined!"`); + }); + + it('throws error when registering removed task type', () => { + expect(() => { + definitions.registerTaskDefinitions({ + sampleTaskRemovedType: { + title: 'removed', + createTaskRunner: jest.fn(), + }, + }); + }).toThrowErrorMatchingInlineSnapshot( + `"Task sampleTaskRemovedType has been removed from registration!"` + ); + }); + }); }); diff --git a/x-pack/plugins/task_manager/server/task_type_dictionary.ts b/x-pack/plugins/task_manager/server/task_type_dictionary.ts index 3bc60284efc8f..a2ea46122acf8 100644 --- a/x-pack/plugins/task_manager/server/task_type_dictionary.ts +++ b/x-pack/plugins/task_manager/server/task_type_dictionary.ts @@ -8,6 +8,17 @@ import { TaskDefinition, taskDefinitionSchema, TaskRunCreatorFunction } from './task'; import { Logger } from '../../../../src/core/server'; +/** + * Types that are no longer registered and will be marked as unregistered + */ +export const REMOVED_TYPES: string[] = [ + // for testing + 'sampleTaskRemovedType', + + // deprecated in https://github.com/elastic/kibana/pull/121442 + 'alerting:siem.signals', +]; + /** * Defines a task which can be scheduled and run by the Kibana * task manager. @@ -109,6 +120,11 @@ export class TaskTypeDictionary { throw new Error(`Task ${duplicate} is already defined!`); } + const removed = Object.keys(taskDefinitions).find((type) => REMOVED_TYPES.indexOf(type) >= 0); + if (removed) { + throw new Error(`Task ${removed} has been removed from registration!`); + } + try { for (const definition of sanitizeTaskDefinitions(taskDefinitions)) { this.definitions.set(definition.type, definition); diff --git a/x-pack/test/functional/es_archives/task_manager_removed_types/data.json b/x-pack/test/functional/es_archives/task_manager_removed_types/data.json index 8594e9d567b8a..3fc1a2cad2d28 100644 --- a/x-pack/test/functional/es_archives/task_manager_removed_types/data.json +++ b/x-pack/test/functional/es_archives/task_manager_removed_types/data.json @@ -1,3 +1,34 @@ +{ + "type": "doc", + "value": { + "id": "task:ce7e1250-3322-11eb-94c1-db6995e83f6b", + "index": ".kibana_task_manager_1", + "source": { + "migrationVersion": { + "task": "7.6.0" + }, + "references": [ + ], + "task": { + "attempts": 0, + "params": "{\"originalParams\":{},\"superFly\":\"My middleware param!\"}", + "retryAt": "2020-11-30T15:43:39.626Z", + "runAt": "2020-11-30T15:43:08.277Z", + "scheduledAt": "2020-11-30T15:43:08.277Z", + "scope": [ + "testing" + ], + "startedAt": null, + "state": "{}", + "status": "idle", + "taskType": "sampleTaskNotRegisteredType" + }, + "type": "task", + "updated_at": "2020-11-30T15:43:08.277Z" + } + } +} + { "type": "doc", "value": { diff --git a/x-pack/test/functional/es_archives/task_manager_tasks/data.json b/x-pack/test/functional/es_archives/task_manager_tasks/data.json index 3431419dda17e..2b92c18dcd47b 100644 --- a/x-pack/test/functional/es_archives/task_manager_tasks/data.json +++ b/x-pack/test/functional/es_archives/task_manager_tasks/data.json @@ -90,3 +90,65 @@ } } } + +{ + "type": "doc", + "value": { + "id": "task:ce7e1250-3322-11eb-94c1-db6995e84f6d", + "index": ".kibana_task_manager_1", + "source": { + "migrationVersion": { + "task": "7.16.0" + }, + "references": [ + ], + "task": { + "attempts": 0, + "params": "{\"spaceId\":\"user1\",\"alertId\":\"0359d7fcc04da9878ee9aadbda38ba55\"}", + "retryAt": "2020-11-30T15:43:39.626Z", + "runAt": "2020-11-30T15:43:08.277Z", + "scheduledAt": "2020-11-30T15:43:08.277Z", + "scope": [ + "testing" + ], + "startedAt": null, + "state": "{}", + "status": "unrecognized", + "taskType": "alerting:0359d7fcc04da9878ee9aadbda38ba55" + }, + "type": "task", + "updated_at": "2020-11-30T15:43:08.277Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "task:fe7e1250-3322-11eb-94c1-db6395e84f6e", + "index": ".kibana_task_manager_1", + "source": { + "migrationVersion": { + "task": "7.16.0" + }, + "references": [ + ], + "task": { + "attempts": 0, + "params": "{\"spaceId\":\"user1\",\"alertId\":\"0359d7fcc04da9878ee9aadbda38ba55\"}", + "retryAt": "2020-11-30T15:43:39.626Z", + "runAt": "2020-11-30T15:43:08.277Z", + "scheduledAt": "2020-11-30T15:43:08.277Z", + "scope": [ + "testing" + ], + "startedAt": null, + "state": "{}", + "status": "unrecognized", + "taskType": "sampleTaskRemovedType" + }, + "type": "task", + "updated_at": "2020-11-30T15:43:08.277Z" + } + } +} \ No newline at end of file diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/migrations.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/migrations.ts index 1e6bb11c13583..1b0ffdedb0077 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/migrations.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/migrations.ts @@ -104,5 +104,37 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(hit!._source!.task.attempts).to.be(0); expect(hit!._source!.task.status).to.be(TaskStatus.Idle); }); + + it('8.2.0 migrates tasks with unrecognized status to idle if task type is removed', async () => { + const response = await es.get<{ task: ConcreteTaskInstance }>( + { + index: '.kibana_task_manager', + id: 'task:ce7e1250-3322-11eb-94c1-db6995e84f6d', + }, + { + meta: true, + } + ); + expect(response.statusCode).to.eql(200); + expect(response.body._source?.task.taskType).to.eql( + `alerting:0359d7fcc04da9878ee9aadbda38ba55` + ); + expect(response.body._source?.task.status).to.eql(`idle`); + }); + + it('8.2.0 does not migrate tasks with unrecognized status if task type is valid', async () => { + const response = await es.get<{ task: ConcreteTaskInstance }>( + { + index: '.kibana_task_manager', + id: 'task:fe7e1250-3322-11eb-94c1-db6395e84f6e', + }, + { + meta: true, + } + ); + expect(response.statusCode).to.eql(200); + expect(response.body._source?.task.taskType).to.eql(`sampleTaskRemovedType`); + expect(response.body._source?.task.status).to.eql(`unrecognized`); + }); }); } diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management_removed_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management_removed_types.ts index 61223b8b67e64..90590f1e3e572 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management_removed_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management_removed_types.ts @@ -45,9 +45,10 @@ export default function ({ getService }: FtrProviderContext) { const config = getService('config'); const request = supertest(url.format(config.get('servers.kibana'))); + const UNREGISTERED_TASK_TYPE_ID = 'ce7e1250-3322-11eb-94c1-db6995e83f6b'; const REMOVED_TASK_TYPE_ID = 'be7e1250-3322-11eb-94c1-db6995e83f6a'; - describe('removed task types', () => { + describe('not registered task types', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/task_manager_removed_types'); }); @@ -76,7 +77,7 @@ export default function ({ getService }: FtrProviderContext) { .then((response) => response.body); } - it('should successfully schedule registered tasks and mark unregistered tasks as unrecognized', async () => { + it('should successfully schedule registered tasks, not claim unregistered tasks and mark removed task types as unrecognized', async () => { const scheduledTask = await scheduleTask({ taskType: 'sampleTask', schedule: { interval: `1s` }, @@ -85,16 +86,21 @@ export default function ({ getService }: FtrProviderContext) { await retry.try(async () => { const tasks = (await currentTasks()).docs; - expect(tasks.length).to.eql(2); + expect(tasks.length).to.eql(3); const taskIds = tasks.map((task) => task.id); expect(taskIds).to.contain(scheduledTask.id); + expect(taskIds).to.contain(UNREGISTERED_TASK_TYPE_ID); expect(taskIds).to.contain(REMOVED_TASK_TYPE_ID); const scheduledTaskInstance = tasks.find((task) => task.id === scheduledTask.id); + const unregisteredTaskInstance = tasks.find( + (task) => task.id === UNREGISTERED_TASK_TYPE_ID + ); const removedTaskInstance = tasks.find((task) => task.id === REMOVED_TASK_TYPE_ID); expect(scheduledTaskInstance?.status).to.eql('claiming'); + expect(unregisteredTaskInstance?.status).to.eql('idle'); expect(removedTaskInstance?.status).to.eql('unrecognized'); }); }); From dde4d6e9daef0954ce997c97a1b2970d97cdcb77 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Zahid Date: Tue, 15 Feb 2022 16:33:43 +0100 Subject: [PATCH 11/39] [Uptime][Monitor Management] Use push flyout to show Test Run Results (#125017) (uptime/issues/445) * Make run-once test results appear in a push flyout. Fixup tooltip. Fixup action buttons order. * Wrapping Monitor Fields form rows when Test Run flyout is open. * Only show step duration trend if it's an already saved monitor. Stop showing "Failed to run steps" until Test Run steps are done loading. uptime/issues/445 Co-authored-by: shahzad31 --- .../fleet_package/browser/advanced_fields.tsx | 17 ++-- .../browser/throttling_fields.tsx | 18 ++-- .../components/fleet_package/code_editor.tsx | 15 +++- .../common/described_form_group_with_wrap.tsx | 23 +++++ .../fleet_package/custom_fields.tsx | 29 ++++-- .../fleet_package/http/advanced_fields.tsx | 20 +++-- .../fleet_package/tcp/advanced_fields.tsx | 23 +++-- .../action_bar/action_bar.test.tsx | 12 +-- .../action_bar/action_bar.tsx | 69 +++++++++----- .../action_bar/action_bar_errors.test.tsx | 4 +- .../edit_monitor_config.tsx | 2 +- .../monitor_advanced_fields.tsx | 15 ++-- .../monitor_config/monitor_config.tsx | 90 +++++++++++++------ .../monitor_config/monitor_fields.tsx | 10 ++- .../monitor_config/monitor_name_location.tsx | 2 +- .../browser/browser_test_results.test.tsx | 18 +++- .../browser/browser_test_results.tsx | 24 ++++- .../use_browser_run_once_monitors.test.tsx | 1 + .../browser/use_browser_run_once_monitors.ts | 3 +- .../simple/simple_test_results.test.tsx | 17 +++- .../simple/simple_test_results.tsx | 6 +- .../test_now_mode/test_now_mode.test.tsx | 12 ++- .../test_now_mode/test_now_mode.tsx | 33 +++++-- .../test_now_mode/test_run_results.tsx | 8 +- .../synthetics/check_steps/step_duration.tsx | 26 ++++-- .../synthetics/check_steps/steps_list.tsx | 10 ++- .../pages/monitor_management/add_monitor.tsx | 2 +- 27 files changed, 362 insertions(+), 147 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/fleet_package/common/described_form_group_with_wrap.tsx diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx index cf72c7562d390..f838474f5219b 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx @@ -13,10 +13,10 @@ import { EuiFieldText, EuiCheckbox, EuiFormRow, - EuiDescribedFormGroup, EuiSpacer, } from '@elastic/eui'; import { ComboBox } from '../combo_box'; +import { DescribedFormGroupWithWrap } from '../common/described_form_group_with_wrap'; import { useBrowserAdvancedFieldsContext, useBrowserSimpleFieldsContext } from '../contexts'; @@ -28,9 +28,10 @@ import { ThrottlingFields } from './throttling_fields'; interface Props { validate: Validation; children?: React.ReactNode; + minColumnWidth?: string; } -export const BrowserAdvancedFields = memo(({ validate, children }) => { +export const BrowserAdvancedFields = memo(({ validate, children, minColumnWidth }) => { const { fields, setFields } = useBrowserAdvancedFieldsContext(); const { fields: simpleFields } = useBrowserSimpleFieldsContext(); @@ -49,7 +50,8 @@ export const BrowserAdvancedFields = memo(({ validate, children }) => { > {simpleFields[ConfigKey.SOURCE_ZIP_URL] && ( - (({ validate, children }) => { data-test-subj="syntheticsBrowserJourneyFiltersTags" /> - + )} - (({ validate, children }) => { data-test-subj="syntheticsBrowserSyntheticsArgs" /> - + - + {children} ); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/throttling_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/throttling_fields.tsx index 6d52ef755d0a2..d5ec96ebb5a6f 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/throttling_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/throttling_fields.tsx @@ -7,14 +7,8 @@ import React, { memo, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiDescribedFormGroup, - EuiSwitch, - EuiSpacer, - EuiFormRow, - EuiFieldNumber, - EuiText, -} from '@elastic/eui'; +import { EuiSwitch, EuiSpacer, EuiFormRow, EuiFieldNumber, EuiText } from '@elastic/eui'; +import { DescribedFormGroupWithWrap } from '../common/described_form_group_with_wrap'; import { OptionalLabel } from '../optional_label'; import { useBrowserAdvancedFieldsContext } from '../contexts'; @@ -22,6 +16,7 @@ import { Validation, ConfigKey } from '../types'; interface Props { validate: Validation; + minColumnWidth?: string; } type ThrottlingConfigs = @@ -30,7 +25,7 @@ type ThrottlingConfigs = | ConfigKey.UPLOAD_SPEED | ConfigKey.LATENCY; -export const ThrottlingFields = memo(({ validate }) => { +export const ThrottlingFields = memo(({ validate, minColumnWidth }) => { const { fields, setFields } = useBrowserAdvancedFieldsContext(); const handleInputChange = useCallback( @@ -148,7 +143,8 @@ export const ThrottlingFields = memo(({ validate }) => { ) : null; return ( - (({ validate }) => { } /> {throttlingInputs} - + ); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx b/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx index 3f80b5f9f365e..ee3ede16582f9 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx @@ -9,6 +9,7 @@ import React from 'react'; import styled from 'styled-components'; import { EuiPanel } from '@elastic/eui'; +import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; import { CodeEditor as MonacoCodeEditor } from '../../../../../../src/plugins/kibana_react/public'; import { MonacoEditorLangId } from './types'; @@ -28,7 +29,11 @@ interface Props { export const CodeEditor = ({ ariaLabel, id, languageId, onChange, value }: Props) => { return ( -
+ -
+
); }; + +const MonacoCodeContainer = euiStyled.div` + & > .kibanaCodeEditor { + z-index: 0; + } +`; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/common/described_form_group_with_wrap.tsx b/x-pack/plugins/uptime/public/components/fleet_package/common/described_form_group_with_wrap.tsx new file mode 100644 index 0000000000000..5668b6f1121c8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/common/described_form_group_with_wrap.tsx @@ -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. + */ +import { EuiDescribedFormGroup } from '@elastic/eui'; +import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; + +/** + * EuiForm group doesn't expose props to control the flex wrapping on flex groups defining form rows. + * This override allows to define a minimum column width to which the Described Form's flex rows should wrap. + */ +export const DescribedFormGroupWithWrap = euiStyled(EuiDescribedFormGroup)<{ + minColumnWidth?: string; +}>` + > .euiFlexGroup { + ${({ minColumnWidth }) => (minColumnWidth ? `flex-wrap: wrap;` : '')} + > .euiFlexItem { + ${({ minColumnWidth }) => (minColumnWidth ? `min-width: ${minColumnWidth};` : '')} + } + } +`; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx index 10aa01ba9361d..638f91fb32d21 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx @@ -14,11 +14,11 @@ import { EuiFormRow, EuiSelect, EuiSpacer, - EuiDescribedFormGroup, EuiSwitch, EuiCallOut, EuiLink, } from '@elastic/eui'; +import { DescribedFormGroupWithWrap } from './common/described_form_group_with_wrap'; import { ConfigKey, DataStream, Validation } from './types'; import { usePolicyConfigContext } from './contexts'; import { TLSFields } from './tls_fields'; @@ -36,6 +36,7 @@ interface Props { dataStreams?: DataStream[]; children?: React.ReactNode; appendAdvancedFields?: React.ReactNode; + minColumnWidth?: string; } const dataStreamToString = [ @@ -54,7 +55,7 @@ const dataStreamToString = [ ]; export const CustomFields = memo( - ({ validate, dataStreams = [], children, appendAdvancedFields }) => { + ({ validate, dataStreams = [], children, appendAdvancedFields, minColumnWidth }) => { const { monitorType, setMonitorType, isTLSEnabled, setIsTLSEnabled, isEditable } = usePolicyConfigContext(); @@ -86,7 +87,8 @@ export const CustomFields = memo( return ( - ( {renderSimpleFields(monitorType)} - + {(isHTTP || isTCP) && ( - ( onChange={(event) => setIsTLSEnabled(event.target.checked)} /> - + )} {isHTTP && ( - {appendAdvancedFields} + + {appendAdvancedFields} + + )} + {isTCP && ( + + {appendAdvancedFields} + )} - {isTCP && {appendAdvancedFields}} {isBrowser && ( - {appendAdvancedFields} + + {appendAdvancedFields} + )} {isICMP && {appendAdvancedFields}} diff --git a/x-pack/plugins/uptime/public/components/fleet_package/http/advanced_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/http/advanced_fields.tsx index 35c6eb6ffa9e3..e4dd68f50f52c 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/http/advanced_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/http/advanced_fields.tsx @@ -14,11 +14,11 @@ import { EuiFieldText, EuiFormRow, EuiSelect, - EuiDescribedFormGroup, EuiCheckbox, EuiSpacer, EuiFieldPassword, } from '@elastic/eui'; +import { DescribedFormGroupWithWrap } from '../common/described_form_group_with_wrap'; import { useHTTPAdvancedFieldsContext } from '../contexts'; @@ -33,9 +33,10 @@ import { ComboBox } from '../combo_box'; interface Props { validate: Validation; children?: React.ReactNode; + minColumnWidth?: string; } -export const HTTPAdvancedFields = memo(({ validate, children }) => { +export const HTTPAdvancedFields = memo(({ validate, children, minColumnWidth }) => { const { fields, setFields } = useHTTPAdvancedFieldsContext(); const handleInputChange = useCallback( ({ value, configKey }: { value: unknown; configKey: ConfigKey }) => { @@ -56,7 +57,8 @@ export const HTTPAdvancedFields = memo(({ validate, children }) => { data-test-subj="syntheticsHTTPAdvancedFieldsAccordion" > - (({ validate, children }) => { )} /> - + - (({ validate, children }) => { )} /> - - + (({ validate, children }) => { data-test-subj="syntheticsResponseBodyCheckNegative" /> - + {children} ); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/tcp/advanced_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/tcp/advanced_fields.tsx index 46e1a739c57c1..ab185b34085bc 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/tcp/advanced_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/tcp/advanced_fields.tsx @@ -7,14 +7,8 @@ import React, { memo, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiAccordion, - EuiCheckbox, - EuiFormRow, - EuiDescribedFormGroup, - EuiFieldText, - EuiSpacer, -} from '@elastic/eui'; +import { EuiAccordion, EuiCheckbox, EuiFormRow, EuiFieldText, EuiSpacer } from '@elastic/eui'; +import { DescribedFormGroupWithWrap } from '../common/described_form_group_with_wrap'; import { useTCPAdvancedFieldsContext } from '../contexts'; @@ -24,9 +18,10 @@ import { OptionalLabel } from '../optional_label'; interface Props { children?: React.ReactNode; + minColumnWidth?: string; } -export const TCPAdvancedFields = memo(({ children }) => { +export const TCPAdvancedFields = memo(({ children, minColumnWidth }) => { const { fields, setFields } = useTCPAdvancedFieldsContext(); const handleInputChange = useCallback( @@ -43,7 +38,8 @@ export const TCPAdvancedFields = memo(({ children }) => { data-test-subj="syntheticsTCPAdvancedFieldsAccordion" > - (({ children }) => { data-test-subj="syntheticsTCPRequestSendCheck" /> - - + (({ children }) => { data-test-subj="syntheticsTCPResponseReceiveCheck" /> - + {children} ); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.test.tsx index adc2a0a8ed344..64b7984b00b40 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.test.tsx @@ -35,7 +35,7 @@ describe('', () => { }); it('only calls setMonitor when valid and after submission', () => { - render(); + render(); act(() => { userEvent.click(screen.getByText('Save monitor')); @@ -45,7 +45,7 @@ describe('', () => { }); it('does not call setMonitor until submission', () => { - render(); + render(); expect(setMonitor).not.toBeCalled(); @@ -57,7 +57,7 @@ describe('', () => { }); it('does not call setMonitor if invalid', () => { - render(); + render(); expect(setMonitor).not.toBeCalled(); @@ -69,7 +69,7 @@ describe('', () => { }); it('disables button and displays help text when form is invalid after first submission', async () => { - render(); + render(); expect( screen.queryByText('Your monitor has errors. Please fix them before saving.') @@ -90,7 +90,9 @@ describe('', () => { it('calls option onSave when saving monitor', () => { const onSave = jest.fn(); - render(); + render( + + ); act(() => { userEvent.click(screen.getByText('Save monitor')); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.tsx b/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.tsx index 4d0d20d548673..f54031766be8e 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.tsx @@ -13,7 +13,7 @@ import { EuiButton, EuiButtonEmpty, EuiText, - EuiToolTip, + EuiPopover, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -37,11 +37,19 @@ export interface ActionBarProps { monitor: SyntheticsMonitor; isValid: boolean; testRun?: TestRun; + isTestRunInProgress: boolean; onSave?: () => void; onTestNow?: () => void; } -export const ActionBar = ({ monitor, isValid, onSave, onTestNow, testRun }: ActionBarProps) => { +export const ActionBar = ({ + monitor, + isValid, + onSave, + onTestNow, + testRun, + isTestRunInProgress, +}: ActionBarProps) => { const { monitorId } = useParams<{ monitorId: string }>(); const { basePath } = useContext(UptimeSettingsContext); const { locations } = useSelector(monitorManagementListSelector); @@ -49,6 +57,7 @@ export const ActionBar = ({ monitor, isValid, onSave, onTestNow, testRun }: Acti const [hasBeenSubmitted, setHasBeenSubmitted] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isSuccessful, setIsSuccessful] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(undefined); const { data, status } = useFetcher(() => { if (!isSaving || !isValid) { @@ -94,7 +103,7 @@ export const ActionBar = ({ monitor, isValid, onSave, onTestNow, testRun }: Acti }); setIsSuccessful(true); } else if (hasErrors && !loading) { - Object.values(data).forEach((location) => { + Object.values(data!).forEach((location) => { const { status: responseStatus, reason } = location.error || {}; kibanaService.toasts.addWarning({ title: i18n.translate('xpack.uptime.monitorManagement.service.error.title', { @@ -144,35 +153,51 @@ export const ActionBar = ({ monitor, isValid, onSave, onTestNow, testRun }: Acti - {onTestNow && ( - - - onTestNow()} - disabled={!isValid} - data-test-subj={'monitorTestNowRunBtn'} - > - {testRun ? RE_RUN_TEST_LABEL : RUN_TEST_LABEL} - - - - )} - {DISCARD_LABEL} + {onTestNow && ( + + {/* Popover is used instead of EuiTooltip until the resolution of https://github.com/elastic/eui/issues/5604 */} + onTestNow()} + onMouseEnter={() => { + setIsPopoverOpen(true); + }} + onMouseLeave={() => { + setIsPopoverOpen(false); + }} + > + {testRun ? RE_RUN_TEST_LABEL : RUN_TEST_LABEL} + + } + isOpen={isPopoverOpen} + > + +

{TEST_NOW_DESCRIPTION}

+
+
+
+ )} + Service Errors', () => { status: FETCH_STATUS.SUCCESS, refetch: () => {}, }); - render(, { state: mockLocationsState }); + render(, { + state: mockLocationsState, + }); userEvent.click(screen.getByText('Save monitor')); await waitFor(() => { diff --git a/x-pack/plugins/uptime/public/components/monitor_management/edit_monitor_config.tsx b/x-pack/plugins/uptime/public/components/monitor_management/edit_monitor_config.tsx index 015d9c2f9dfdf..2f2014f405bd2 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/edit_monitor_config.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/edit_monitor_config.tsx @@ -88,7 +88,7 @@ export const EditMonitorConfig = ({ monitor }: Props) => { browserDefaultValues={fullDefaultConfig[DataStream.BROWSER]} tlsDefaultValues={defaultTLSConfig} > - + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_advanced_fields.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_advanced_fields.tsx index 5cecdf9a385bd..21ef7d12dcd59 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_advanced_fields.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_advanced_fields.tsx @@ -6,17 +6,19 @@ */ import React, { memo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFormRow, EuiSpacer, EuiDescribedFormGroup, EuiLink, EuiFieldText } from '@elastic/eui'; -import type { Validation } from '../../../../common/types/index'; -import { ConfigKey } from '../../../../common/runtime_types/monitor_management'; +import { EuiFormRow, EuiSpacer, EuiLink, EuiFieldText } from '@elastic/eui'; +import type { Validation } from '../../../../common/types'; +import { ConfigKey } from '../../../../common/runtime_types'; +import { DescribedFormGroupWithWrap } from '../../fleet_package/common/described_form_group_with_wrap'; import { usePolicyConfigContext } from '../../fleet_package/contexts'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; interface Props { validate: Validation; + minColumnWidth?: string; } -export const MonitorManagementAdvancedFields = memo(({ validate }) => { +export const MonitorManagementAdvancedFields = memo(({ validate, minColumnWidth }) => { const { namespace, setNamespace } = usePolicyConfigContext(); const namespaceErrorMsg = validate[ConfigKey.NAMESPACE]?.({ @@ -26,7 +28,8 @@ export const MonitorManagementAdvancedFields = memo(({ validate }) => { const { services } = useKibana(); return ( - (({ validate }) => { name="namespace" /> - + ); }); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_config.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_config.tsx index bcade36929805..c12e3a3f49939 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_config.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_config.tsx @@ -5,9 +5,17 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; -import { EuiResizableContainer } from '@elastic/eui'; +import { + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlyout, + EuiSpacer, + EuiFlyoutFooter, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { v4 as uuidv4 } from 'uuid'; import { defaultConfig, usePolicyConfigContext } from '../../fleet_package/contexts'; @@ -19,7 +27,7 @@ import { MonitorFields } from './monitor_fields'; import { TestNowMode, TestRun } from '../test_now_mode/test_now_mode'; import { MonitorFields as MonitorFieldsType } from '../../../../common/runtime_types'; -export const MonitorConfig = () => { +export const MonitorConfig = ({ isEdit = false }: { isEdit: boolean }) => { const { monitorType } = usePolicyConfigContext(); /* raw policy config compatible with the UI. Save this to saved objects */ @@ -37,46 +45,70 @@ export const MonitorConfig = () => { }); const [testRun, setTestRun] = useState(); + const [isTestRunInProgress, setIsTestRunInProgress] = useState(false); + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); - const onTestNow = () => { + const handleTestNow = () => { if (config) { setTestRun({ id: uuidv4(), monitor: config as MonitorFieldsType }); + setIsTestRunInProgress(true); + setIsFlyoutOpen(true); } }; + const handleTestDone = useCallback(() => { + setIsTestRunInProgress(false); + }, [setIsTestRunInProgress]); + + const handleFlyoutClose = useCallback(() => { + handleTestDone(); + setIsFlyoutOpen(false); + }, [handleTestDone, setIsFlyoutOpen]); + + const flyout = isFlyoutOpen && config && ( + + + + + + + + + + {CLOSE_LABEL} + + + + ); + return ( <> - - {(EuiResizablePanel, EuiResizableButton) => ( - <> - - - - - - - - {config && } - - - )} - + + + {flyout} ); }; + +const TEST_RESULT = i18n.translate('xpack.uptime.monitorManagement.testResult', { + defaultMessage: 'Test result', +}); + +const CLOSE_LABEL = i18n.translate('xpack.uptime.monitorManagement.closeButtonLabel', { + defaultMessage: 'Close', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_fields.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_fields.tsx index 9e72a810f821c..32783460aed09 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_fields.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_fields.tsx @@ -15,14 +15,22 @@ import { validate } from '../validation'; import { MonitorNameAndLocation } from './monitor_name_location'; import { MonitorManagementAdvancedFields } from './monitor_advanced_fields'; +const MIN_COLUMN_WRAP_WIDTH = '360px'; + export const MonitorFields = () => { const { monitorType } = usePolicyConfigContext(); return ( } + appendAdvancedFields={ + + } > diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_name_location.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_name_location.tsx index b5a4c7c4f7b0f..7ba80f411c6f1 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_name_location.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_name_location.tsx @@ -43,7 +43,7 @@ export const MonitorNameAndLocation = ({ validate }: Props) => { defaultMessage="Monitor name" /> } - fullWidth={true} + fullWidth={false} isInvalid={isNameInvalid || nameAlreadyExists} error={ nameAlreadyExists ? ( diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.test.tsx index 727dfa4b9ec31..d164e19705838 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.test.tsx @@ -14,8 +14,16 @@ import { BrowserTestRunResult } from './browser_test_results'; import { fireEvent } from '@testing-library/dom'; describe('BrowserTestRunResult', function () { + const onDone = jest.fn(); + let testId: string; + + beforeEach(() => { + testId = 'test-id'; + jest.resetAllMocks(); + }); + it('should render properly', async function () { - render(); + render(); expect(await screen.findByText('Test result')).toBeInTheDocument(); expect(await screen.findByText('0 steps completed')).toBeInTheDocument(); const dataApi = (kibanaService.core as any).data.search; @@ -28,7 +36,7 @@ describe('BrowserTestRunResult', function () { query: { bool: { filter: [ - { term: { config_id: 'test-id' } }, + { term: { config_id: testId } }, { terms: { 'synthetics.type': ['heartbeat/summary', 'journey/start'], @@ -52,12 +60,13 @@ describe('BrowserTestRunResult', function () { data, stepListData: { steps: [stepEndDoc._source] } as any, loading: false, + stepsLoading: false, journeyStarted: true, summaryDoc: summaryDoc._source, stepEnds: [stepEndDoc._source], }); - render(); + render(); expect(await screen.findByText('Test result')).toBeInTheDocument(); @@ -69,6 +78,9 @@ describe('BrowserTestRunResult', function () { expect(await screen.findByText('Go to https://www.elastic.co/')).toBeInTheDocument(); expect(await screen.findByText('21.8 seconds')).toBeInTheDocument(); + + // Calls onDone on completion + expect(onDone).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx index 5dc893356b214..c6074626bad1e 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { useEffect } from 'react'; import * as React from 'react'; import { EuiAccordion, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -16,13 +17,21 @@ import { TestResultHeader } from '../test_result_header'; interface Props { monitorId: string; + isMonitorSaved: boolean; + onDone: () => void; } -export const BrowserTestRunResult = ({ monitorId }: Props) => { - const { data, loading, stepEnds, journeyStarted, summaryDoc, stepListData } = +export const BrowserTestRunResult = ({ monitorId, isMonitorSaved, onDone }: Props) => { + const { data, loading, stepsLoading, stepEnds, journeyStarted, summaryDoc, stepListData } = useBrowserRunOnceMonitors({ configId: monitorId, }); + useEffect(() => { + if (Boolean(summaryDoc)) { + onDone(); + } + }, [summaryDoc, onDone]); + const hits = data?.hits.hits; const doc = hits?.[0]?._source as JourneyStep; @@ -50,6 +59,10 @@ export const BrowserTestRunResult = ({ monitorId }: Props) => {
); + const isStepsLoading = + journeyStarted && stepEnds.length === 0 && (!summaryDoc || (summaryDoc && stepsLoading)); + const isStepsLoadingFailed = summaryDoc && stepEnds.length === 0 && !isStepsLoading; + return ( { buttonContent={buttonContent} paddingSize="s" data-test-subj="expandResults" + initialIsOpen={true} > - {summaryDoc && stepEnds.length === 0 && {FAILED_TO_RUN}} - {!summaryDoc && journeyStarted && stepEnds.length === 0 && {LOADING_STEPS}} + {isStepsLoading && {LOADING_STEPS}} + {isStepsLoadingFailed && {FAILED_TO_RUN}} + {stepEnds.length > 0 && stepListData?.steps && ( diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.test.tsx index f467bb642a13e..285a4a4140c27 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.test.tsx @@ -34,6 +34,7 @@ describe('useBrowserRunOnceMonitors', function () { data: undefined, journeyStarted: false, loading: true, + stepsLoading: true, stepEnds: [], stepListData: undefined, summaryDoc: undefined, diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.ts b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.ts index d051eaebe392e..04605373f369e 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.ts +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.ts @@ -86,7 +86,7 @@ export const useBrowserRunOnceMonitors = ({ const { data, loading } = useBrowserEsResults({ configId, testRunId, lastRefresh }); - const { data: stepListData } = useFetcher(() => { + const { data: stepListData, loading: stepsLoading } = useFetcher(() => { if (checkGroupId && !skipDetails) { return fetchJourneySteps({ checkGroup: checkGroupId, @@ -122,6 +122,7 @@ export const useBrowserRunOnceMonitors = ({ data, stepEnds, loading, + stepsLoading, stepListData, summaryDoc: summary, journeyStarted: Boolean(checkGroupId), diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.test.tsx index 99ed9ac43db1b..1d5dfef8a67e7 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.test.tsx @@ -14,8 +14,16 @@ import * as runOnceHooks from './use_simple_run_once_monitors'; import { Ping } from '../../../../../common/runtime_types'; describe('SimpleTestResults', function () { + const onDone = jest.fn(); + let testId: string; + + beforeEach(() => { + testId = 'test-id'; + jest.resetAllMocks(); + }); + it('should render properly', async function () { - render(); + render(); expect(await screen.findByText('Test result')).toBeInTheDocument(); const dataApi = (kibanaService.core as any).data.search; @@ -26,7 +34,7 @@ describe('SimpleTestResults', function () { body: { query: { bool: { - filter: [{ term: { config_id: 'test-id' } }, { exists: { field: 'summary' } }], + filter: [{ term: { config_id: testId } }, { exists: { field: 'summary' } }], }, }, sort: [{ '@timestamp': 'desc' }], @@ -51,7 +59,7 @@ describe('SimpleTestResults', function () { loading: false, }); - render(); + render(); expect(await screen.findByText('Test result')).toBeInTheDocument(); @@ -61,6 +69,9 @@ describe('SimpleTestResults', function () { expect(await screen.findByText('Checked Jan 12, 2022 11:54:27 AM')).toBeInTheDocument(); expect(await screen.findByText('Took 191 ms')).toBeInTheDocument(); + // Calls onDone on completion + expect(onDone).toHaveBeenCalled(); + screen.debug(); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.tsx index 507082c7fefb1..4fb27fb83d560 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.tsx @@ -12,16 +12,18 @@ import { TestResultHeader } from '../test_result_header'; interface Props { monitorId: string; + onDone: () => void; } -export function SimpleTestResults({ monitorId }: Props) { +export function SimpleTestResults({ monitorId, onDone }: Props) { const [summaryDocs, setSummaryDocs] = useState([]); const { summaryDoc, loading } = useSimpleRunOnceMonitors({ configId: monitorId }); useEffect(() => { if (summaryDoc) { setSummaryDocs((prevState) => [summaryDoc, ...prevState]); + onDone(); } - }, [summaryDoc]); + }, [summaryDoc, onDone]); return ( <> diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.test.tsx index 849f1215614d0..4a3f155a18813 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.test.tsx @@ -13,9 +13,19 @@ import { kibanaService } from '../../../state/kibana_service'; import { MonitorFields } from '../../../../common/runtime_types'; describe('TestNowMode', function () { + const onDone = jest.fn(); + + afterEach(() => { + jest.resetAllMocks(); + }); + it('should render properly', async function () { render( - + ); expect(await screen.findByText('Test result')).toBeInTheDocument(); expect(await screen.findByText('PENDING')).toBeInTheDocument(); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.tsx index 43d4e0e6e9d2a..a4f04e04ddc14 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiCallOut, @@ -26,13 +26,25 @@ export interface TestRun { monitor: MonitorFields; } -export function TestNowMode({ testRun }: { testRun?: TestRun }) { +export function TestNowMode({ + testRun, + isMonitorSaved, + onDone, +}: { + testRun?: TestRun; + isMonitorSaved: boolean; + onDone: () => void; +}) { + const [serviceError, setServiceError] = useState(null); + const { data, loading: isPushing } = useFetcher(() => { if (testRun) { return runOnceMonitor({ monitor: testRun.monitor, id: testRun.id, - }); + }) + .then(() => setServiceError(null)) + .catch((error) => setServiceError(error)); } return new Promise((resolve) => resolve(null)); }, [testRun]); @@ -49,7 +61,13 @@ export function TestNowMode({ testRun }: { testRun?: TestRun }) { const errors = (data as { errors?: Array<{ error: Error }> })?.errors; - const hasErrors = errors && errors?.length > 0; + const hasErrors = serviceError || (errors && errors?.length > 0); + + useEffect(() => { + if (!isPushing && (!testRun || hasErrors)) { + onDone(); + } + }, [testRun, hasErrors, isPushing, onDone]); if (!testRun) { return null; @@ -68,7 +86,12 @@ export function TestNowMode({ testRun }: { testRun?: TestRun }) { {testRun && !hasErrors && !isPushing && ( - + )} diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_run_results.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_run_results.tsx index 4b261815e9949..27c9eb8426a31 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_run_results.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_run_results.tsx @@ -13,11 +13,13 @@ import { SimpleTestResults } from './simple/simple_test_results'; interface Props { monitorId: string; monitor: SyntheticsMonitor; + isMonitorSaved: boolean; + onDone: () => void; } -export const TestRunResult = ({ monitorId, monitor }: Props) => { +export const TestRunResult = ({ monitorId, monitor, isMonitorSaved, onDone }: Props) => { return monitor.type === 'browser' ? ( - + ) : ( - + ); }; diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_duration.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_duration.tsx index a9697a8969f65..d9c8eee59ecc1 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_duration.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_duration.tsx @@ -8,7 +8,7 @@ import type { MouseEvent } from 'react'; import * as React from 'react'; -import { EuiButtonEmpty, EuiPopover } from '@elastic/eui'; +import { EuiButtonEmpty, EuiPopover, EuiText } from '@elastic/eui'; import { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { JourneyStep } from '../../../../common/runtime_types'; @@ -16,6 +16,7 @@ import { StepFieldTrend } from './step_field_trend'; import { microToSec } from '../../../lib/formatting'; interface Props { + showStepDurationTrend?: boolean; compactView?: boolean; step: JourneyStep; durationPopoverOpenIndex: number | null; @@ -26,8 +27,20 @@ export const StepDuration = ({ step, durationPopoverOpenIndex, setDurationPopoverOpenIndex, + showStepDurationTrend = true, compactView = false, }: Props) => { + const stepDurationText = useMemo( + () => + i18n.translate('xpack.uptime.synthetics.step.duration', { + defaultMessage: '{value} seconds', + values: { + value: microToSec(step.synthetics.step?.duration.us!, 1), + }, + }), + [step.synthetics.step?.duration.us] + ); + const component = useMemo( () => ( --; } + if (!showStepDurationTrend) { + return {stepDurationText}; + } + const button = ( setDurationPopoverOpenIndex(step.synthetics.step?.index ?? null)} iconType={compactView ? undefined : 'visArea'} > - {i18n.translate('xpack.uptime.synthetics.step.duration', { - defaultMessage: '{value} seconds', - values: { - value: microToSec(step.synthetics.step?.duration.us!, 1), - }, - })} + {stepDurationText} ); diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx index d635d76fc3f89..40362df3df5fc 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx @@ -37,6 +37,7 @@ interface Props { error?: Error; loading: boolean; compactView?: boolean; + showStepDurationTrend?: boolean; } interface StepStatusCount { @@ -85,7 +86,13 @@ function reduceStepStatus(prev: StepStatusCount, cur: JourneyStep): StepStatusCo return prev; } -export const StepsList = ({ data, error, loading, compactView = false }: Props) => { +export const StepsList = ({ + data, + error, + loading, + showStepDurationTrend = true, + compactView = false, +}: Props) => { const steps: JourneyStep[] = data.filter(isStepEnd); const { expandedRows, toggleExpand } = useExpandedRow({ steps, allSteps: data, loading }); @@ -140,6 +147,7 @@ export const StepsList = ({ data, error, loading, compactView = false }: Props) step={item} durationPopoverOpenIndex={durationPopoverOpenIndex} setDurationPopoverOpenIndex={setDurationPopoverOpenIndex} + showStepDurationTrend={showStepDurationTrend} compactView={compactView} /> ); diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx index bc8737ccd4b35..dbf1c1214abd0 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx @@ -37,7 +37,7 @@ export const AddMonitorPage: React.FC = () => { allowedScheduleUnits: [ScheduleUnit.MINUTES], }} > - + ); From 35f4ba536262d37946a48922d30997e98ff74620 Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Tue, 15 Feb 2022 16:46:05 +0100 Subject: [PATCH 12/39] revert package policy validation that caused issue with input groups (#125657) --- .../services/validate_package_policy.test.ts | 3 ++- .../common/services/validate_package_policy.ts | 14 +++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts b/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts index afb6a2f806f9a..975d45fd01c64 100644 --- a/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts @@ -607,7 +607,8 @@ describe('Fleet - validatePackagePolicy()', () => { }); }); - it('returns package policy validation error if input var does not exist', () => { + // TODO enable when https://github.com/elastic/kibana/issues/125655 is fixed + it.skip('returns package policy validation error if input var does not exist', () => { expect( validatePackagePolicy( { diff --git a/x-pack/plugins/fleet/common/services/validate_package_policy.ts b/x-pack/plugins/fleet/common/services/validate_package_policy.ts index f1e28bfbe4e55..2a8c187d71629 100644 --- a/x-pack/plugins/fleet/common/services/validate_package_policy.ts +++ b/x-pack/plugins/fleet/common/services/validate_package_policy.ts @@ -210,15 +210,11 @@ export const validatePackagePolicyConfig = ( } if (varDef === undefined) { - errors.push( - i18n.translate('xpack.fleet.packagePolicyValidation.nonExistentVarMessage', { - defaultMessage: '{varName} var definition does not exist', - values: { - varName, - }, - }) - ); - return errors; + // TODO return validation error here once https://github.com/elastic/kibana/issues/125655 is fixed + // eslint-disable-next-line no-console + console.debug(`No variable definition for ${varName} found`); + + return null; } if (varDef.required) { From 181d04c2e19f4391c553a0cd4790e5f10858321b Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 15 Feb 2022 10:53:50 -0500 Subject: [PATCH 13/39] [Fleet] Fix output form validation for trusted fingerprint (#125662) --- .../output_form_validators.test.tsx | 23 ++++++++++++++++++- .../output_form_validators.tsx | 2 +- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx index 1ad49dc091412..4f8b147e80448 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx @@ -5,7 +5,11 @@ * 2.0. */ -import { validateHosts, validateYamlConfig } from './output_form_validators'; +import { + validateHosts, + validateYamlConfig, + validateCATrustedFingerPrint, +} from './output_form_validators'; describe('Output form validation', () => { describe('validateHosts', () => { @@ -72,4 +76,21 @@ describe('Output form validation', () => { } }); }); + describe('validate', () => { + it('should work with a valid fingerprint', () => { + const res = validateCATrustedFingerPrint( + '9f0a10411457adde3982ef01df20d2e7aa53a8ef29c50bcbfa3f3e93aebf631b' + ); + + expect(res).toBeUndefined(); + }); + + it('should return an error with a invalid formatted fingerprint', () => { + const res = validateCATrustedFingerPrint( + '9F:0A:10:41:14:57:AD:DE:39:82:EF:01:DF:20:D2:E7:AA:53:A8:EF:29:C5:0B:CB:FA:3F:3E:93:AE:BF:63:1B' + ); + + expect(res).toEqual(['CA trusted fingerprint should be a base64 CA sha256 fingerprint']); + }); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx index d4a84e4e1bc8c..3a9e42c152cc3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx @@ -73,7 +73,7 @@ export function validateName(value: string) { } export function validateCATrustedFingerPrint(value: string) { - if (value !== '' && !value.match(/^[a-zA-Z0-9]$/)) { + if (value !== '' && !value.match(/^[a-zA-Z0-9]+$/)) { return [ i18n.translate('xpack.fleet.settings.outputForm.caTrusterdFingerprintInvalidErrorMessage', { defaultMessage: 'CA trusted fingerprint should be a base64 CA sha256 fingerprint', From ea059d4a3696abfb7ef6292c215db2fdc73bfddf Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Tue, 15 Feb 2022 16:57:02 +0100 Subject: [PATCH 14/39] Use Discover locator to generate URL (#124282) * use Discover locator to generate URL * improve locator check * do not import discover plugin * allow for share plugin to be missing Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../helpers/setup_environment.tsx | 7 ++--- .../add_docs_accordion/add_docs_accordion.tsx | 31 ++++--------------- .../public/application/index.tsx | 4 +-- .../application/mount_management_section.ts | 2 +- 4 files changed, 11 insertions(+), 33 deletions(-) diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx index a2c36e204cbea..8e128692c41c5 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -12,6 +12,7 @@ import { LocationDescriptorObject } from 'history'; import { HttpSetup } from 'kibana/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { sharePluginMock } from '../../../../../../src/plugins/share/public/mocks'; import { notificationServiceMock, docLinksServiceMock, @@ -48,11 +49,7 @@ const appServices = { notifications: notificationServiceMock.createSetupContract(), history, uiSettings: uiSettingsServiceMock.createSetupContract(), - urlGenerators: { - getUrlGenerator: jest.fn().mockReturnValue({ - createUrl: jest.fn(), - }), - }, + url: sharePluginMock.createStartContract().url, fileUpload: { getMaxBytes: jest.fn().mockReturnValue(100), getMaxBytesFormatted: jest.fn().mockReturnValue('100'), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.tsx index ed817498586a6..e6454b207ab35 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.tsx @@ -10,7 +10,6 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiAccordion, EuiText, EuiSpacer, EuiLink } from '@elastic/eui'; -import { UrlGeneratorsDefinition } from 'src/plugins/share/public'; import { useKibana } from '../../../../../../../../shared_imports'; import { useIsMounted } from '../../../../../use_is_mounted'; @@ -18,8 +17,6 @@ import { AddDocumentForm } from '../add_document_form'; import './add_docs_accordion.scss'; -const DISCOVER_URL_GENERATOR_ID = 'DISCOVER_APP_URL_GENERATOR'; - const i18nTexts = { addDocumentsButton: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.addDocumentsAccordion.addDocumentsButtonLabel', @@ -46,34 +43,18 @@ export const AddDocumentsAccordion: FunctionComponent = ({ onAddDocuments useEffect(() => { const getDiscoverUrl = async (): Promise => { - let isDeprecated: UrlGeneratorsDefinition['isDeprecated']; - let createUrl: UrlGeneratorsDefinition['createUrl']; - - // This try/catch may not be necessary once - // https://github.com/elastic/kibana/issues/78344 is addressed - try { - ({ isDeprecated, createUrl } = - services.urlGenerators.getUrlGenerator(DISCOVER_URL_GENERATOR_ID)); - } catch (e) { - // Discover plugin is not enabled + const locator = services.share?.url.locators.get('DISCOVER_APP_LOCATOR'); + if (!locator) { setDiscoverLink(undefined); return; } - - if (isDeprecated) { - setDiscoverLink(undefined); - return; - } - - const discoverUrl = await createUrl({ indexPatternId: undefined }); - - if (isMounted.current) { - setDiscoverLink(discoverUrl); - } + const discoverUrl = await locator.getUrl({ indexPatternId: undefined }); + if (!isMounted.current) return; + setDiscoverLink(discoverUrl); }; getDiscoverUrl(); - }, [isMounted, services.urlGenerators]); + }, [isMounted, services.share]); return ( Date: Tue, 15 Feb 2022 17:07:36 +0100 Subject: [PATCH 15/39] [DOCS] Removes technical preview flag from DFA ML page. (#125497) --- docs/user/ml/index.asciidoc | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/user/ml/index.asciidoc b/docs/user/ml/index.asciidoc index f6a47d7b9d618..e66ca4ee19780 100644 --- a/docs/user/ml/index.asciidoc +++ b/docs/user/ml/index.asciidoc @@ -26,7 +26,8 @@ If {stack-security-features} are enabled, users must have the necessary privileges to use {ml-features}. Refer to {ml-docs}/setup.html#setup-privileges[Set up {ml-features}]. -NOTE: There are limitations in {ml-features} that affect {kib}. For more information, refer to {ml-docs}/ml-limitations.html[Machine learning]. +NOTE: There are limitations in {ml-features} that affect {kib}. For more +information, refer to {ml-docs}/ml-limitations.html[{ml-cap}]. -- @@ -84,8 +85,6 @@ and {ml-docs}/ml-ad-overview.html[{ml-cap} {anomaly-detect}]. [[xpack-ml-dfanalytics]] == {dfanalytics-cap} -experimental[] - The Elastic {ml} {dfanalytics} feature enables you to analyze your data using {classification}, {oldetection}, and {regression} algorithms and generate new indices that contain the results alongside your source data. From 4ee8a02a28dadc9b5c30cf62268a8f9a03b80408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Tue, 15 Feb 2022 17:19:14 +0100 Subject: [PATCH 16/39] Remove meta field before create/update event filter item (#125624) --- .../pages/event_filters/service/service_actions.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts index 40de6de881431..787d8495a3d45 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts @@ -76,6 +76,8 @@ export async function addEventFilters( exception: ExceptionListItemSchema | CreateExceptionListItemSchema ) { await ensureEventFiltersListExists(http); + // Clean meta data before create event flter as the API throws an error with it + delete exception.meta; return http.post(EXCEPTION_LIST_ITEM_URL, { body: JSON.stringify(exception), }); @@ -134,14 +136,13 @@ export function cleanEventFilterToUpdate( const exceptionToUpdateCleaned = { ...exception }; // Clean unnecessary fields for update action [ - 'created_at', - 'created_by', 'created_at', 'created_by', 'list_id', 'tie_breaker_id', 'updated_at', 'updated_by', + 'meta', ].forEach((field) => { delete exceptionToUpdateCleaned[field as keyof UpdateExceptionListItemSchema]; }); From 8fabaf3fae095d0e484083160a5f2cf75ef98a68 Mon Sep 17 00:00:00 2001 From: Jan Monschke Date: Tue, 15 Feb 2022 17:20:08 +0100 Subject: [PATCH 17/39] [SecuritySolution][Threat Hunting] Fix a couple of field ids for highlighted fields (#124941) * fix: use correct DNS field id * fix: for behavior alerts we should display rule.description --- .../event_details/alert_summary_view.test.tsx | 79 ++++++++++++++----- .../event_details/get_alert_summary_rows.tsx | 10 +-- 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx index 25de792731d44..fff723cd31cf4 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx @@ -116,6 +116,40 @@ describe('AlertSummaryView', () => { expect(getByText(fieldId)); }); }); + + test('DNS event renders the correct summary rows', () => { + const renderProps = { + ...props, + data: [ + ...(mockAlertDetailsData.map((item) => { + if (item.category === 'event' && item.field === 'event.category') { + return { + ...item, + values: ['dns'], + originalValue: ['dns'], + }; + } + return item; + }) as TimelineEventsDetailsItem[]), + { + category: 'dns', + field: 'dns.question.name', + values: ['www.example.com'], + originalValue: ['www.example.com'], + } as TimelineEventsDetailsItem, + ], + }; + const { getByText } = render( + + + + ); + + ['dns.question.name', 'process.name'].forEach((fieldId) => { + expect(getByText(fieldId)); + }); + }); + test('Memory event code renders additional summary rows', () => { const renderProps = { ...props, @@ -140,32 +174,41 @@ describe('AlertSummaryView', () => { }); }); test('Behavior event code renders additional summary rows', () => { + const actualRuleDescription = 'The actual rule description'; const renderProps = { ...props, - data: mockAlertDetailsData.map((item) => { - if (item.category === 'event' && item.field === 'event.code') { - return { - ...item, - values: ['behavior'], - originalValue: ['behavior'], - }; - } - if (item.category === 'event' && item.field === 'event.category') { - return { - ...item, - values: ['malware', 'process', 'file'], - originalValue: ['malware', 'process', 'file'], - }; - } - return item; - }) as TimelineEventsDetailsItem[], + data: [ + ...mockAlertDetailsData.map((item) => { + if (item.category === 'event' && item.field === 'event.code') { + return { + ...item, + values: ['behavior'], + originalValue: ['behavior'], + }; + } + if (item.category === 'event' && item.field === 'event.category') { + return { + ...item, + values: ['malware', 'process', 'file'], + originalValue: ['malware', 'process', 'file'], + }; + } + return item; + }), + { + category: 'rule', + field: 'rule.description', + values: [actualRuleDescription], + originalValue: [actualRuleDescription], + }, + ] as TimelineEventsDetailsItem[], }; const { getByText } = render( ); - ['host.name', 'user.name', 'process.name'].forEach((fieldId) => { + ['host.name', 'user.name', 'process.name', actualRuleDescription].forEach((fieldId) => { expect(getByText(fieldId)); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx index 3da4ecab77992..35f6b71b1dacf 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx @@ -6,11 +6,7 @@ */ import { find, isEmpty, uniqBy } from 'lodash/fp'; -import { - ALERT_RULE_NAMESPACE, - ALERT_RULE_TYPE, - ALERT_RULE_DESCRIPTION, -} from '@kbn/rule-data-utils'; +import { ALERT_RULE_NAMESPACE, ALERT_RULE_TYPE } from '@kbn/rule-data-utils'; import * as i18n from './translations'; import { BrowserFields } from '../../../../common/search_strategy/index_fields'; @@ -69,7 +65,7 @@ function getFieldsByCategory({ { id: 'process.name' }, ]; case EventCategory.DNS: - return [{ id: 'dns.query.name' }, { id: 'process.name' }]; + return [{ id: 'dns.question.name' }, { id: 'process.name' }]; case EventCategory.REGISTRY: return [{ id: 'registry.key' }, { id: 'registry.value' }, { id: 'process.name' }]; case EventCategory.MALWARE: @@ -107,7 +103,7 @@ function getFieldsByEventCode( switch (eventCode) { case EventCode.BEHAVIOR: return [ - { id: ALERT_RULE_DESCRIPTION, label: ALERTS_HEADERS_RULE_DESCRIPTION }, + { id: 'rule.description', label: ALERTS_HEADERS_RULE_DESCRIPTION }, // Resolve more fields based on the source event ...getFieldsByCategory({ ...eventCategories, primaryEventCategory: undefined }), ]; From 8b2a18cca02a976f291ee63db3aba4c64eaf9856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Tue, 15 Feb 2022 11:46:28 -0500 Subject: [PATCH 18/39] [APM] Bug: Service maps popover detail metrics are aggregates over all transaction types (#125580) * fixing failure transaction rate on service maps * addressing PR comments --- .../get_failed_transaction_rate.ts | 6 +- .../get_service_map_service_node_info.ts | 1 + .../get_failed_transaction_rate_periods.ts | 4 +- .../tests/error_rate/service_maps.spec.ts | 149 ++++++++++++++++++ .../tests/latency/service_maps.spec.ts | 127 +++++++++++++++ .../tests/throughput/service_maps.spec.ts | 122 ++++++++++++++ 6 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 x-pack/test/apm_api_integration/tests/error_rate/service_maps.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/latency/service_maps.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/throughput/service_maps.spec.ts diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts index 4bd49f0db15e1..e3b5c995d0563 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts @@ -35,7 +35,7 @@ export async function getFailedTransactionRate({ environment, kuery, serviceName, - transactionType, + transactionTypes, transactionName, setup, searchAggregatedTransactions, @@ -46,7 +46,7 @@ export async function getFailedTransactionRate({ environment: string; kuery: string; serviceName: string; - transactionType?: string; + transactionTypes: string[]; transactionName?: string; setup: Setup; searchAggregatedTransactions: boolean; @@ -66,8 +66,8 @@ export async function getFailedTransactionRate({ [EVENT_OUTCOME]: [EventOutcome.failure, EventOutcome.success], }, }, + { terms: { [TRANSACTION_TYPE]: transactionTypes } }, ...termQuery(TRANSACTION_NAME, transactionName), - ...termQuery(TRANSACTION_TYPE, transactionType), ...getDocumentTypeFilterForTransactions(searchAggregatedTransactions), ...rangeQuery(start, end), ...environmentQuery(environment), diff --git a/x-pack/plugins/apm/server/routes/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/routes/service_map/get_service_map_service_node_info.ts index ec6c13de76fb1..884da3991d731 100644 --- a/x-pack/plugins/apm/server/routes/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/routes/service_map/get_service_map_service_node_info.ts @@ -144,6 +144,7 @@ async function getFailedTransactionsRateStats({ end, kuery: '', numBuckets, + transactionTypes: [TRANSACTION_REQUEST, TRANSACTION_PAGE_LOAD], }); return { value: average, diff --git a/x-pack/plugins/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts b/x-pack/plugins/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts index 709c867377aff..96913b9e197a7 100644 --- a/x-pack/plugins/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts +++ b/x-pack/plugins/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts @@ -24,7 +24,7 @@ export async function getFailedTransactionRatePeriods({ environment: string; kuery: string; serviceName: string; - transactionType?: string; + transactionType: string; transactionName?: string; setup: Setup; searchAggregatedTransactions: boolean; @@ -37,7 +37,7 @@ export async function getFailedTransactionRatePeriods({ environment, kuery, serviceName, - transactionType, + transactionTypes: [transactionType], transactionName, setup, searchAggregatedTransactions, diff --git a/x-pack/test/apm_api_integration/tests/error_rate/service_maps.spec.ts b/x-pack/test/apm_api_integration/tests/error_rate/service_maps.spec.ts new file mode 100644 index 0000000000000..4dddff70958ba --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/error_rate/service_maps.spec.ts @@ -0,0 +1,149 @@ +/* + * 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 '@elastic/apm-synthtrace'; +import expect from '@kbn/expect'; +import { meanBy } from 'lodash'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const serviceName = 'synth-go'; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function getErrorRateValues(processorEvent: 'transaction' | 'metric') { + const commonQuery = { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + environment: 'ENVIRONMENT_ALL', + }; + const [serviceInventoryAPIResponse, serviceMapsNodeDetails] = await Promise.all([ + apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services', + params: { + query: { + ...commonQuery, + kuery: `service.name : "${serviceName}" and processor.event : "${processorEvent}"`, + }, + }, + }), + apmApiClient.readUser({ + endpoint: `GET /internal/apm/service-map/service/{serviceName}`, + params: { + path: { serviceName }, + query: commonQuery, + }, + }), + ]); + + const serviceInventoryErrorRate = + serviceInventoryAPIResponse.body.items[0].transactionErrorRate; + + const serviceMapsNodeDetailsErrorRate = meanBy( + serviceMapsNodeDetails.body.currentPeriod.failedTransactionsRate?.timeseries, + 'y' + ); + + return { + serviceInventoryErrorRate, + serviceMapsNodeDetailsErrorRate, + }; + } + + let errorRateMetricValues: Awaited>; + let errorTransactionValues: Awaited>; + registry.when( + 'Service maps APIs', + { config: 'trial', archives: ['apm_mappings_only_8.0.0'] }, + () => { + describe('when data is loaded ', () => { + const GO_PROD_LIST_RATE = 75; + const GO_PROD_LIST_ERROR_RATE = 25; + const GO_PROD_ID_RATE = 50; + const GO_PROD_ID_ERROR_RATE = 50; + before(async () => { + const serviceGoProdInstance = apm + .service(serviceName, 'production', 'go') + .instance('instance-a'); + + const transactionNameProductList = 'GET /api/product/list'; + const transactionNameProductId = 'GET /api/product/:id'; + + await synthtraceEsClient.index([ + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_LIST_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction(transactionNameProductList, 'Worker') + .timestamp(timestamp) + .duration(1000) + .success() + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_LIST_ERROR_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction(transactionNameProductList, 'Worker') + .duration(1000) + .timestamp(timestamp) + .failure() + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_ID_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction(transactionNameProductId) + .timestamp(timestamp) + .duration(1000) + .success() + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_ID_ERROR_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction(transactionNameProductId) + .duration(1000) + .timestamp(timestamp) + .failure() + .serialize() + ), + ]); + }); + + after(() => synthtraceEsClient.clean()); + + describe('compare latency value between service inventory and service maps', () => { + before(async () => { + [errorTransactionValues, errorRateMetricValues] = await Promise.all([ + getErrorRateValues('transaction'), + getErrorRateValues('metric'), + ]); + }); + + it('returns same avg error rate value for Transaction-based and Metric-based data', () => { + [ + errorTransactionValues.serviceInventoryErrorRate, + errorTransactionValues.serviceMapsNodeDetailsErrorRate, + errorRateMetricValues.serviceInventoryErrorRate, + errorRateMetricValues.serviceMapsNodeDetailsErrorRate, + ].forEach((value) => expect(value).to.be.equal(GO_PROD_ID_ERROR_RATE / 100)); + }); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/latency/service_maps.spec.ts b/x-pack/test/apm_api_integration/tests/latency/service_maps.spec.ts new file mode 100644 index 0000000000000..f977c35bfa54f --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/latency/service_maps.spec.ts @@ -0,0 +1,127 @@ +/* + * 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 '@elastic/apm-synthtrace'; +import expect from '@kbn/expect'; +import { meanBy } from 'lodash'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const serviceName = 'synth-go'; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function getLatencyValues(processorEvent: 'transaction' | 'metric') { + const commonQuery = { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + environment: 'ENVIRONMENT_ALL', + }; + const [serviceInventoryAPIResponse, serviceMapsNodeDetails] = await Promise.all([ + apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services', + params: { + query: { + ...commonQuery, + kuery: `service.name : "${serviceName}" and processor.event : "${processorEvent}"`, + }, + }, + }), + apmApiClient.readUser({ + endpoint: `GET /internal/apm/service-map/service/{serviceName}`, + params: { + path: { serviceName }, + query: commonQuery, + }, + }), + ]); + + const serviceInventoryLatency = serviceInventoryAPIResponse.body.items[0].latency; + + const serviceMapsNodeDetailsLatency = meanBy( + serviceMapsNodeDetails.body.currentPeriod.transactionStats?.latency?.timeseries, + 'y' + ); + + return { + serviceInventoryLatency, + serviceMapsNodeDetailsLatency, + }; + } + + let latencyMetricValues: Awaited>; + let latencyTransactionValues: Awaited>; + registry.when( + 'Service maps APIs', + { config: 'trial', archives: ['apm_mappings_only_8.0.0'] }, + () => { + describe('when data is loaded ', () => { + const GO_PROD_RATE = 80; + const GO_DEV_RATE = 20; + const GO_PROD_DURATION = 1000; + const GO_DEV_DURATION = 500; + before(async () => { + const serviceGoProdInstance = apm + .service(serviceName, 'production', 'go') + .instance('instance-a'); + const serviceGoDevInstance = apm + .service(serviceName, 'development', 'go') + .instance('instance-b'); + + await synthtraceEsClient.index([ + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction('GET /api/product/list', 'Worker') + .duration(GO_PROD_DURATION) + .timestamp(timestamp) + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_DEV_RATE) + .flatMap((timestamp) => + serviceGoDevInstance + .transaction('GET /api/product/:id') + .duration(GO_DEV_DURATION) + .timestamp(timestamp) + .serialize() + ), + ]); + }); + + after(() => synthtraceEsClient.clean()); + + describe('compare latency value between service inventory and service maps', () => { + before(async () => { + [latencyTransactionValues, latencyMetricValues] = await Promise.all([ + getLatencyValues('transaction'), + getLatencyValues('metric'), + ]); + }); + + it('returns same avg latency value for Transaction-based and Metric-based data', () => { + const expectedLatencyAvgValueMs = + ((GO_DEV_RATE * GO_DEV_DURATION) / GO_DEV_RATE) * 1000; + + [ + latencyTransactionValues.serviceMapsNodeDetailsLatency, + latencyTransactionValues.serviceInventoryLatency, + latencyMetricValues.serviceMapsNodeDetailsLatency, + latencyMetricValues.serviceInventoryLatency, + ].forEach((value) => expect(value).to.be.equal(expectedLatencyAvgValueMs)); + }); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/throughput/service_maps.spec.ts b/x-pack/test/apm_api_integration/tests/throughput/service_maps.spec.ts new file mode 100644 index 0000000000000..adbae6dff2096 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/throughput/service_maps.spec.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 { apm, timerange } from '@elastic/apm-synthtrace'; +import expect from '@kbn/expect'; +import { meanBy } from 'lodash'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { roundNumber } from '../../utils'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const serviceName = 'synth-go'; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function getThroughputValues(processorEvent: 'transaction' | 'metric') { + const commonQuery = { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + environment: 'ENVIRONMENT_ALL', + }; + const [serviceInventoryAPIResponse, serviceMapsNodeDetails] = await Promise.all([ + apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services', + params: { + query: { + ...commonQuery, + kuery: `service.name : "${serviceName}" and processor.event : "${processorEvent}"`, + }, + }, + }), + apmApiClient.readUser({ + endpoint: `GET /internal/apm/service-map/service/{serviceName}`, + params: { + path: { serviceName }, + query: commonQuery, + }, + }), + ]); + + const serviceInventoryThroughput = serviceInventoryAPIResponse.body.items[0].throughput; + + const serviceMapsNodeDetailsThroughput = meanBy( + serviceMapsNodeDetails.body.currentPeriod.transactionStats?.throughput?.timeseries, + 'y' + ); + + return { + serviceInventoryThroughput, + serviceMapsNodeDetailsThroughput, + }; + } + + let throughputMetricValues: Awaited>; + let throughputTransactionValues: Awaited>; + + registry.when( + 'Service maps APIs', + { config: 'trial', archives: ['apm_mappings_only_8.0.0'] }, + () => { + describe('when data is loaded ', () => { + const GO_PROD_RATE = 80; + const GO_DEV_RATE = 20; + before(async () => { + const serviceGoProdInstance = apm + .service(serviceName, 'production', 'go') + .instance('instance-a'); + const serviceGoDevInstance = apm + .service(serviceName, 'development', 'go') + .instance('instance-b'); + + await synthtraceEsClient.index([ + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction('GET /api/product/list', 'Worker') + .duration(1000) + .timestamp(timestamp) + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_DEV_RATE) + .flatMap((timestamp) => + serviceGoDevInstance + .transaction('GET /api/product/:id') + .duration(1000) + .timestamp(timestamp) + .serialize() + ), + ]); + }); + + after(() => synthtraceEsClient.clean()); + + describe('compare throughput value between service inventory and service maps', () => { + before(async () => { + [throughputTransactionValues, throughputMetricValues] = await Promise.all([ + getThroughputValues('transaction'), + getThroughputValues('metric'), + ]); + }); + + it('returns same throughput value for Transaction-based and Metric-based data', () => { + [ + ...Object.values(throughputTransactionValues), + ...Object.values(throughputMetricValues), + ].forEach((value) => expect(roundNumber(value)).to.be.equal(roundNumber(GO_DEV_RATE))); + }); + }); + }); + } + ); +} From 48ce5d18ae7b9ed4ca86d667cccef269f886090c Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 15 Feb 2022 18:20:01 +0100 Subject: [PATCH 19/39] [Lens][Example] Testing playground for Lens embeddables (#125061) * :alembic: First pass * :sparkles: Add Lens testing playground * :sparkles: Add partition preset * :ok_hand: Addressed feedback * :lipstick: Add thumbnail for example catalog * :wrench: Convert image to png * :fire: Remove extra debug buttons * :bug: Fix error handling * :bug: Fix buttons bug --- .../embedded_lens_example/public/app.tsx | 65 +- .../testing_embedded_lens/.eslintrc.json | 5 + .../examples/testing_embedded_lens/README.md | 7 + .../testing_embedded_lens/kibana.json | 21 + .../testing_embedded_lens/package.json | 14 + .../testing_embedded_lens/public/app.tsx | 730 ++++++++++++++++++ .../testing_embedded_lens/public/image.png | Bin 0 -> 167283 bytes .../testing_embedded_lens/public/index.ts | 10 + .../testing_embedded_lens/public/mount.tsx | 49 ++ .../testing_embedded_lens/public/plugin.ts | 55 ++ .../testing_embedded_lens/tsconfig.json | 21 + 11 files changed, 928 insertions(+), 49 deletions(-) create mode 100644 x-pack/examples/testing_embedded_lens/.eslintrc.json create mode 100644 x-pack/examples/testing_embedded_lens/README.md create mode 100644 x-pack/examples/testing_embedded_lens/kibana.json create mode 100644 x-pack/examples/testing_embedded_lens/package.json create mode 100644 x-pack/examples/testing_embedded_lens/public/app.tsx create mode 100644 x-pack/examples/testing_embedded_lens/public/image.png create mode 100644 x-pack/examples/testing_embedded_lens/public/index.ts create mode 100644 x-pack/examples/testing_embedded_lens/public/mount.tsx create mode 100644 x-pack/examples/testing_embedded_lens/public/plugin.ts create mode 100644 x-pack/examples/testing_embedded_lens/tsconfig.json diff --git a/x-pack/examples/embedded_lens_example/public/app.tsx b/x-pack/examples/embedded_lens_example/public/app.tsx index 2e2e973e7cc6b..950e2f454ad2e 100644 --- a/x-pack/examples/embedded_lens_example/public/app.tsx +++ b/x-pack/examples/embedded_lens_example/public/app.tsx @@ -127,9 +127,6 @@ export const App = (props: { to: 'now', }); - const [enableExtraAction, setEnableExtraAction] = useState(false); - const [enableDefaultAction, setEnableDefaultAction] = useState(false); - const LensComponent = props.plugins.lens.EmbeddableComponent; const LensSaveModalComponent = props.plugins.lens.SaveModalComponent; @@ -242,34 +239,10 @@ export const App = (props: { Change time range - - { - setEnableExtraAction((prevState) => !prevState); - }} - > - {enableExtraAction ? 'Disable extra action' : 'Enable extra action'} - - - - { - setEnableDefaultAction((prevState) => !prevState); - }} - > - {enableDefaultAction ? 'Disable default action' : 'Enable default action'} - - 'save', - async isCompatible( - context: ActionExecutionContext - ): Promise { - return true; - }, - execute: async (context: ActionExecutionContext) => { - alert('I am an extra action'); - return; - }, - getDisplayName: () => 'Extra action', - }, - ] - : undefined - } + extraActions={[ + { + id: 'testAction', + type: 'link', + getIconType: () => 'save', + async isCompatible(context: ActionExecutionContext): Promise { + return true; + }, + execute: async (context: ActionExecutionContext) => { + alert('I am an extra action'); + return; + }, + getDisplayName: () => 'Extra action', + }, + ]} /> {isSaveModalVisible && ( ; + +function getInitialType(dataView: DataView) { + return dataView.isTimeBased() ? 'date' : 'number'; +} + +function getColumnFor(type: RequiredType, fieldName: string, isBucketed: boolean = true) { + if (type === 'string') { + return { + label: `Top values of ${fieldName}`, + dataType: 'string', + operationType: 'terms', + scale: 'ordinal', + sourceField: fieldName, + isBucketed: true, + params: { + size: 5, + orderBy: { type: 'alphabetical', fallback: true }, + orderDirection: 'desc', + }, + } as TermsIndexPatternColumn; + } + if (type === 'number') { + if (isBucketed) { + return { + label: fieldName, + dataType: 'number', + operationType: 'range', + sourceField: fieldName, + isBucketed: true, + scale: 'interval', + params: { + type: 'histogram', + maxBars: 'auto', + format: undefined, + parentFormat: undefined, + }, + } as RangeIndexPatternColumn; + } + return { + label: `Median of ${fieldName}`, + dataType: 'number', + operationType: 'median', + sourceField: fieldName, + isBucketed: false, + scale: 'ratio', + } as MedianIndexPatternColumn; + } + return { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { interval: 'auto' }, + scale: 'interval', + sourceField: fieldName, + } as DateHistogramIndexPatternColumn; +} + +function getDataLayer( + type: RequiredType, + field: string, + isBucketed: boolean = true +): PersistedIndexPatternLayer { + return { + columnOrder: ['col1', 'col2'], + columns: { + col2: { + dataType: 'number', + isBucketed: false, + label: 'Count of records', + operationType: 'count', + scale: 'ratio', + sourceField: DOCUMENT_FIELD_NAME, + }, + col1: getColumnFor(type, field, isBucketed), + }, + }; +} + +function getBaseAttributes( + defaultIndexPattern: DataView, + fields: FieldsMap, + type?: RequiredType, + dataLayer?: PersistedIndexPatternLayer +): Omit & { + state: Omit; +} { + const finalType = type ?? getInitialType(defaultIndexPattern); + const finalDataLayer = dataLayer ?? getDataLayer(finalType, fields[finalType]); + return { + title: 'Prefilled from example app', + references: [ + { + id: defaultIndexPattern.id!, + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: defaultIndexPattern.id!, + name: 'indexpattern-datasource-layer-layer1', + type: 'index-pattern', + }, + ], + state: { + datasourceStates: { + indexpattern: { + layers: { + layer1: finalDataLayer, + }, + }, + }, + filters: [], + query: { language: 'kuery', query: '' }, + }, + }; +} + +// Generate a Lens state based on some app-specific input parameters. +// `TypedLensByValueInput` can be used for type-safety - it uses the same interfaces as Lens-internal code. +function getLensAttributes( + defaultIndexPattern: DataView, + fields: FieldsMap, + chartType: 'bar_stacked' | 'line' | 'area', + color: string +): TypedLensByValueInput['attributes'] { + const baseAttributes = getBaseAttributes(defaultIndexPattern, fields); + + const xyConfig: XYState = { + axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + fittingFunction: 'None', + gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + layers: [ + { + accessors: ['col2'], + layerId: 'layer1', + layerType: 'data', + seriesType: chartType, + xAccessor: 'col1', + yConfig: [{ forAccessor: 'col2', color }], + }, + ], + legend: { isVisible: true, position: 'right' }, + preferredSeriesType: chartType, + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + valueLabels: 'hide', + }; + + return { + ...baseAttributes, + visualizationType: 'lnsXY', + state: { + ...baseAttributes.state, + visualization: xyConfig, + }, + }; +} + +function getLensAttributesHeatmap( + defaultIndexPattern: DataView, + fields: FieldsMap +): TypedLensByValueInput['attributes'] { + const initialType = getInitialType(defaultIndexPattern); + const dataLayer = getDataLayer(initialType, fields[initialType]); + const heatmapDataLayer = { + columnOrder: ['col1', 'col3', 'col2'], + columns: { + ...dataLayer.columns, + col3: getColumnFor('string', fields.string) as TermsIndexPatternColumn, + }, + }; + + const baseAttributes = getBaseAttributes( + defaultIndexPattern, + fields, + initialType, + heatmapDataLayer + ); + + const heatmapConfig: HeatmapVisualizationState = { + layerId: 'layer1', + layerType: 'data', + shape: 'heatmap', + xAccessor: 'col1', + yAccessor: 'col3', + valueAccessor: 'col2', + legend: { isVisible: true, position: 'right', type: 'heatmap_legend' }, + gridConfig: { + isCellLabelVisible: true, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + isYAxisTitleVisible: true, + isXAxisTitleVisible: true, + type: 'heatmap_grid', + }, + }; + + return { + ...baseAttributes, + visualizationType: 'lnsHeatmap', + state: { + ...baseAttributes.state, + visualization: heatmapConfig, + }, + }; +} + +function getLensAttributesDatatable( + defaultIndexPattern: DataView, + fields: FieldsMap +): TypedLensByValueInput['attributes'] { + const initialType = getInitialType(defaultIndexPattern); + const baseAttributes = getBaseAttributes(defaultIndexPattern, fields, initialType); + + const tableConfig: DatatableVisualizationState = { + layerId: 'layer1', + layerType: 'data', + columns: [{ columnId: 'col1' }, { columnId: 'col2' }], + }; + + return { + ...baseAttributes, + visualizationType: 'lnsDatatable', + state: { + ...baseAttributes.state, + visualization: tableConfig, + }, + }; +} + +function getLensAttributesGauge( + defaultIndexPattern: DataView, + fields: FieldsMap +): TypedLensByValueInput['attributes'] { + const dataLayer = getDataLayer('number', fields.number, false); + const gaugeDataLayer = { + columnOrder: ['col1'], + columns: { + col1: dataLayer.columns.col1, + }, + }; + + const baseAttributes = getBaseAttributes(defaultIndexPattern, fields, 'number', gaugeDataLayer); + const gaugeConfig: GaugeVisualizationState = { + layerId: 'layer1', + layerType: 'data', + shape: 'horizontalBullet', + ticksPosition: 'auto', + labelMajorMode: 'auto', + metricAccessor: 'col1', + }; + return { + ...baseAttributes, + visualizationType: 'lnsGauge', + state: { + ...baseAttributes.state, + visualization: gaugeConfig, + }, + }; +} + +function getLensAttributesPartition( + defaultIndexPattern: DataView, + fields: FieldsMap +): TypedLensByValueInput['attributes'] { + const baseAttributes = getBaseAttributes(defaultIndexPattern, fields, 'number'); + const pieConfig: PieVisualizationState = { + layers: [ + { + groups: ['col1'], + metric: 'col2', + layerId: 'layer1', + layerType: 'data', + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + }, + ], + shape: 'pie', + }; + return { + ...baseAttributes, + visualizationType: 'lnsPie', + state: { + ...baseAttributes.state, + visualization: pieConfig, + }, + }; +} + +function getFieldsByType(dataView: DataView) { + const aggregatableFields = dataView.fields.filter((f) => f.aggregatable); + const fields: Partial = { + string: aggregatableFields.find((f) => f.type === 'string')?.displayName, + number: aggregatableFields.find((f) => f.type === 'number')?.displayName, + }; + if (dataView.isTimeBased()) { + fields.date = dataView.getTimeField().displayName; + } + // remove undefined values + for (const type of ['string', 'number', 'date'] as const) { + if (typeof fields[type] == null) { + delete fields[type]; + } + } + return fields as FieldsMap; +} + +function isXYChart(attributes: TypedLensByValueInput['attributes']) { + return attributes.visualizationType === 'lnsXY'; +} + +function checkAndParseSO(newSO: string) { + try { + return JSON.parse(newSO) as TypedLensByValueInput['attributes']; + } catch (e) { + // do nothing + } +} +let chartCounter = 1; + +export const App = (props: { + core: CoreStart; + plugins: StartDependencies; + defaultDataView: DataView; + stateHelpers: Awaited>; +}) => { + const [isLoading, setIsLoading] = useState(false); + const [isSaveModalVisible, setIsSaveModalVisible] = useState(false); + const [enableExtraAction, setEnableExtraAction] = useState(false); + const [enableDefaultAction, setEnableDefaultAction] = useState(false); + const [enableTriggers, toggleTriggers] = useState(false); + const [loadedCharts, addChartConfiguration] = useState< + Array<{ id: string; attributes: TypedLensByValueInput['attributes'] }> + >([]); + const [hasParsingError, setErrorFlag] = useState(false); + const [hasParsingErrorDebounced, setErrorDebounced] = useState(hasParsingError); + const LensComponent = props.plugins.lens.EmbeddableComponent; + const LensSaveModalComponent = props.plugins.lens.SaveModalComponent; + + const fields = getFieldsByType(props.defaultDataView); + + const [time, setTime] = useState({ + from: 'now-5d', + to: 'now', + }); + + const defaultCharts = [ + { + id: 'bar_stacked', + attributes: getLensAttributes(props.defaultDataView, fields, 'bar_stacked', 'green'), + }, + { + id: 'line', + attributes: getLensAttributes(props.defaultDataView, fields, 'line', 'green'), + }, + { + id: 'area', + attributes: getLensAttributes(props.defaultDataView, fields, 'area', 'green'), + }, + { id: 'pie', attributes: getLensAttributesPartition(props.defaultDataView, fields) }, + { id: 'table', attributes: getLensAttributesDatatable(props.defaultDataView, fields) }, + { id: 'heatmap', attributes: getLensAttributesHeatmap(props.defaultDataView, fields) }, + { id: 'gauge', attributes: getLensAttributesGauge(props.defaultDataView, fields) }, + ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + const charts = useMemo(() => [...defaultCharts, ...loadedCharts], [loadedCharts]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const initialAttributes = useMemo(() => JSON.stringify(charts[0].attributes, null, 2), []); + + const currentSO = useRef(initialAttributes); + const [currentValid, saveValidSO] = useState(initialAttributes); + const switchChartPreset = useCallback( + (newIndex) => { + const newChart = charts[newIndex]; + const newAttributes = JSON.stringify(newChart.attributes, null, 2); + currentSO.current = newAttributes; + saveValidSO(newAttributes); + }, + [charts] + ); + + const currentAttributes = useMemo(() => { + try { + return JSON.parse(currentSO.current); + } catch (e) { + return JSON.parse(currentValid); + } + }, [currentValid, currentSO]); + + const isDisabled = !currentAttributes; + const isColorDisabled = isDisabled || !isXYChart(currentAttributes); + + useDebounce(() => setErrorDebounced(hasParsingError), 500, [hasParsingError]); + + return ( + + + + + + + +

+ This app embeds a Lens visualization by specifying the configuration. Data + fetching and rendering is completely managed by Lens itself. +

+

+ The editor on the right hand side make it possible to paste a Lens attributes + configuration, and have it rendered. Presets are available to have a starting + configuration, and new presets can be saved as well (not persisted). +

+

+ The Open with Lens button will take the current configuration and navigate to a + prefilled editor. +

+ + + + { + const newColor = `rgb(${[1, 2, 3].map(() => + Math.floor(Math.random() * 256) + )})`; + const newAttributes = JSON.stringify( + getLensAttributes( + props.defaultDataView, + fields, + currentAttributes.state.visualization.preferredSeriesType, + newColor + ), + null, + 2 + ); + currentSO.current = newAttributes; + saveValidSO(newAttributes); + }} + isDisabled={isColorDisabled} + > + Change color + + + + { + setIsSaveModalVisible(true); + }} + > + Save Visualization + + + {props.defaultDataView?.isTimeBased() ? ( + + { + setTime( + time.to === 'now' + ? { + from: '2015-09-18T06:31:44.000Z', + to: '2015-09-23T18:31:44.000Z', + } + : { + from: 'now-5d', + to: 'now', + } + ); + }} + > + {time.to === 'now' ? 'Change time range' : 'Reset time range'} + + + ) : null} + + { + props.plugins.lens.navigateToPrefilledEditor( + { + id: '', + timeRange: time, + attributes: currentAttributes, + }, + { + openInNewTab: true, + } + ); + }} + > + Edit in Lens (new tab) + + + + { + toggleTriggers((prevState) => !prevState); + }} + > + {enableTriggers ? 'Disable triggers' : 'Enable triggers'} + + + + { + setEnableExtraAction((prevState) => !prevState); + }} + > + {enableExtraAction ? 'Disable extra action' : 'Enable extra action'} + + + + { + setEnableDefaultAction((prevState) => !prevState); + }} + > + {enableDefaultAction ? 'Disable default action' : 'Enable default action'} + + + +

State: {isLoading ? 'Loading...' : 'Rendered'}

+
+
+ + + { + setIsLoading(val); + }} + onBrushEnd={({ range }) => { + setTime({ + from: new Date(range[0]).toISOString(), + to: new Date(range[1]).toISOString(), + }); + }} + onFilter={(_data) => { + // call back event for on filter event + }} + onTableRowClick={(_data) => { + // call back event for on table row click event + }} + disableTriggers={!enableTriggers} + viewMode={ViewMode.VIEW} + withDefaultActions={enableDefaultAction} + extraActions={ + enableExtraAction + ? [ + { + id: 'testAction', + type: 'link', + getIconType: () => 'save', + async isCompatible( + context: ActionExecutionContext + ): Promise { + return true; + }, + execute: async (context: ActionExecutionContext) => { + alert('I am an extra action'); + return; + }, + getDisplayName: () => 'Extra action', + }, + ] + : undefined + } + /> + + + + {isSaveModalVisible && ( + {}} + onClose={() => setIsSaveModalVisible(false)} + /> + )} + + + + + + +

Paste or edit here your Lens document

+
+
+
+ + + ({ value: i, text: id }))} + value={undefined} + onChange={(e) => switchChartPreset(Number(e.target.value))} + aria-label="Load from a preset" + /> + + + { + const attributes = checkAndParseSO(currentSO.current); + if (attributes) { + const label = `custom-chart-${chartCounter}`; + addChartConfiguration([ + ...loadedCharts, + { + id: label, + attributes, + }, + ]); + chartCounter++; + alert(`The preset has been saved as "${label}"`); + } + }} + > + Save as preset + + + {hasParsingErrorDebounced && currentSO.current !== currentValid && ( + +

Check the spec

+
+ )} +
+ + + { + const isValid = Boolean(checkAndParseSO(newSO)); + setErrorFlag(!isValid); + currentSO.current = newSO; + if (isValid) { + // reset the debounced error + setErrorDebounced(isValid); + saveValidSO(newSO); + } + }} + /> + + +
+
+ + + + + + ); +}; diff --git a/x-pack/examples/testing_embedded_lens/public/image.png b/x-pack/examples/testing_embedded_lens/public/image.png new file mode 100644 index 0000000000000000000000000000000000000000..15563872fd6a0fb93c7dea58f31609b3db249910 GIT binary patch literal 167283 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 Gg-ou9% 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 new TestingEmbeddedLensPlugin(); diff --git a/x-pack/examples/testing_embedded_lens/public/mount.tsx b/x-pack/examples/testing_embedded_lens/public/mount.tsx new file mode 100644 index 0000000000000..50727871a3545 --- /dev/null +++ b/x-pack/examples/testing_embedded_lens/public/mount.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { EuiCallOut } from '@elastic/eui'; + +import type { CoreSetup, AppMountParameters } from 'kibana/public'; +import type { StartDependencies } from './plugin'; + +export const mount = + (coreSetup: CoreSetup) => + async ({ element }: AppMountParameters) => { + const [core, plugins] = await coreSetup.getStartServices(); + const { App } = await import('./app'); + + const defaultDataView = await plugins.data.indexPatterns.getDefault(); + const stateHelpers = await plugins.lens.stateHelperApi(); + + const i18nCore = core.i18n; + + const reactElement = ( + + {defaultDataView ? ( + + ) : ( + +

This demo only works if your default index pattern is set and time based

+
+ )} +
+ ); + + render(reactElement, element); + return () => unmountComponentAtNode(element); + }; diff --git a/x-pack/examples/testing_embedded_lens/public/plugin.ts b/x-pack/examples/testing_embedded_lens/public/plugin.ts new file mode 100644 index 0000000000000..374f830d0bb2e --- /dev/null +++ b/x-pack/examples/testing_embedded_lens/public/plugin.ts @@ -0,0 +1,55 @@ +/* + * 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, CoreSetup, AppNavLinkStatus } from '../../../../src/core/public'; +import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { LensPublicStart } from '../../../plugins/lens/public'; +import { DeveloperExamplesSetup } from '../../../../examples/developer_examples/public'; +import { mount } from './mount'; +import image from './image.png'; + +export interface SetupDependencies { + developerExamples: DeveloperExamplesSetup; +} + +export interface StartDependencies { + data: DataPublicPluginStart; + lens: LensPublicStart; +} + +export class TestingEmbeddedLensPlugin + implements Plugin +{ + public setup(core: CoreSetup, { developerExamples }: SetupDependencies) { + core.application.register({ + id: 'testing_embedded_lens', + title: 'Embedded Lens testing playground', + navLinkStatus: AppNavLinkStatus.hidden, + mount: mount(core), + }); + + developerExamples.register({ + appId: 'testing_embedded_lens', + title: 'Testing Embedded Lens', + description: 'Testing playground used to test Lens embeddable', + links: [ + { + label: 'README', + href: 'https://github.com/elastic/kibana/tree/main/x-pack/examples/testing_embedded_lens', + iconType: 'logoGithub', + size: 's', + target: '_blank', + }, + ], + image, + }); + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/examples/testing_embedded_lens/tsconfig.json b/x-pack/examples/testing_embedded_lens/tsconfig.json new file mode 100644 index 0000000000000..e1016a6c011a1 --- /dev/null +++ b/x-pack/examples/testing_embedded_lens/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types" + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../../typings/**/*" + ], + "exclude": [], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../plugins/lens/tsconfig.json" }, + { "path": "../../../examples/developer_examples/tsconfig.json" }, + ] +} From 1fe2110e31bdde6afc092fea762f8cfdcba706b1 Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Tue, 15 Feb 2022 18:30:46 +0100 Subject: [PATCH 20/39] [SecuritySolution][Endpoint] Add a user with kibana_system and superuser role for adding fake data (#125476) * add a user with kibana_system and superuser role for adding fake data fixes elastic/security-team/issues/2908 * Check user exists before adding review changes * Delete endpoint_user afterwards review changes * user API response to simplify adding user * simplify type * allow picking username and password from cli review suggestions * do not add new user by default, require it only when fleet is enabled review changes * use URL review changes * update protocol with URL as well --- .../endpoint/resolver_generator_script.ts | 180 +++++++++++++++--- 1 file changed, 154 insertions(+), 26 deletions(-) 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 da0810bead47e..74a51a6e16199 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 @@ -11,21 +11,21 @@ import fs from 'fs'; import { Client, errors } from '@elastic/elasticsearch'; import type { ClientOptions } from '@elastic/elasticsearch/lib/client'; import { ToolingLog, CA_CERT_PATH } from '@kbn/dev-utils'; -import { KbnClient } from '@kbn/test'; +import { KbnClient, KbnClientOptions } from '@kbn/test'; import { indexHostsAndAlerts } from '../../common/endpoint/index_data'; import { ANCESTRY_LIMIT, EndpointDocGenerator } from '../../common/endpoint/generate_data'; main(); -async function deleteIndices(indices: string[], client: Client) { - const handleErr = (err: unknown) => { - if (err instanceof errors.ResponseError && err.statusCode !== 404) { - console.log(JSON.stringify(err, null, 2)); - // eslint-disable-next-line no-process-exit - process.exit(1); - } - }; +const handleErr = (err: unknown) => { + if (err instanceof errors.ResponseError && err.statusCode !== 404) { + console.log(JSON.stringify(err, null, 2)); + // eslint-disable-next-line no-process-exit + process.exit(1); + } +}; +async function deleteIndices(indices: string[], client: Client) { for (const index of indices) { try { // The index could be a data stream so let's try deleting that first @@ -37,6 +37,73 @@ async function deleteIndices(indices: string[], client: Client) { } } +interface UserInfo { + username: string; + password: string; +} + +async function addUser( + esClient: Client, + user?: { username: string; password: string } +): Promise { + if (!user) { + return; + } + + const path = `_security/user/${user.username}`; + // add user if doesn't exist already + try { + console.log(`Adding ${user.username}...`); + const addedUser = await esClient.transport.request>({ + method: 'POST', + path, + body: { + password: user.password, + roles: ['superuser', 'kibana_system'], + full_name: user.username, + }, + }); + if (addedUser.created) { + console.log(`User ${user.username} added successfully!`); + } else { + console.log(`User ${user.username} already exists!`); + } + return { + username: user.username, + password: user.password, + }; + } catch (error) { + handleErr(error); + } +} + +async function deleteUser(esClient: Client, username: string): Promise<{ found: boolean }> { + return esClient.transport.request({ + method: 'DELETE', + path: `_security/user/${username}`, + }); +} + +const updateURL = ({ + url, + user, + protocol, +}: { + url: string; + user?: { username: string; password: string }; + protocol?: string; +}): string => { + const urlObject = new URL(url); + if (user) { + urlObject.username = user.username; + urlObject.password = user.password; + } + if (protocol) { + urlObject.protocol = protocol; + } + return urlObject.href; +}; + async function main() { const argv = yargs.help().options({ seed: { @@ -179,35 +246,80 @@ async function main() { type: 'boolean', default: false, }, + withNewUser: { + alias: 'nu', + describe: + 'If the --fleet flag is enabled, using `--withNewUser=username:password` would add a new user with \ + the given username, password and `superuser`, `kibana_system` roles. Adding a new user would also write \ + to indices in the generator as this user with the new roles.', + type: 'string', + default: '', + }, }).argv; let ca: Buffer; - let kbnClient: KbnClient; + let clientOptions: ClientOptions; + let url: string; + let node: string; + const toolingLogOptions = { + log: new ToolingLog({ + level: 'info', + writeTo: process.stdout, + }), + }; + + let kbnClientOptions: KbnClientOptions = { + ...toolingLogOptions, + url: argv.kibana, + }; if (argv.ssl) { ca = fs.readFileSync(CA_CERT_PATH); - const url = argv.kibana.replace('http:', 'https:'); - const node = argv.node.replace('http:', 'https:'); - kbnClient = new KbnClient({ - log: new ToolingLog({ - level: 'info', - writeTo: process.stdout, - }), + url = updateURL({ url: argv.kibana, protocol: 'https:' }); + node = updateURL({ url: argv.node, protocol: 'https:' }); + kbnClientOptions = { + ...kbnClientOptions, url, certificateAuthorities: [ca], - }); + }; + clientOptions = { node, tls: { ca: [ca] } }; } else { - kbnClient = new KbnClient({ - log: new ToolingLog({ - level: 'info', - writeTo: process.stdout, - }), - url: argv.kibana, - }); clientOptions = { node: argv.node }; } - const client = new Client(clientOptions); + let client = new Client(clientOptions); + let user: UserInfo | undefined; + // if fleet flag is used + if (argv.fleet) { + // add endpoint user if --withNewUser flag has values as username:password + const newUserCreds = + argv.withNewUser.indexOf(':') !== -1 ? argv.withNewUser.split(':') : undefined; + user = await addUser( + client, + newUserCreds + ? { + username: newUserCreds[0], + password: newUserCreds[1], + } + : undefined + ); + + // update client and kibana options before instantiating + if (user) { + // use endpoint user for Es and Kibana URLs + + url = updateURL({ url: argv.kibana, user }); + node = updateURL({ url: argv.node, user }); + + kbnClientOptions = { + ...kbnClientOptions, + url, + }; + client = new Client({ ...clientOptions, node }); + } + } + // instantiate kibana client + const kbnClient = new KbnClient({ ...kbnClientOptions }); if (argv.delete) { await deleteIndices( @@ -222,6 +334,14 @@ async function main() { console.log(`No seed supplied, using random seed: ${seed}`); } const startTime = new Date().getTime(); + if (argv.fleet && !argv.withNewUser) { + // warn and exit when using fleet flag + console.log( + 'Please use the --withNewUser=username:password flag to add a custom user with required roles when --fleet is enabled!' + ); + // eslint-disable-next-line no-process-exit + process.exit(0); + } await indexHostsAndAlerts( client, kbnClient, @@ -249,5 +369,13 @@ async function main() { alertsDataStream: EndpointDocGenerator.createDataStreamFromIndex(argv.alertIndex), } ); + // delete endpoint_user after + + if (user) { + const deleted = await deleteUser(client, user.username); + if (deleted.found) { + console.log(`User ${user.username} deleted successfully!`); + } + } console.log(`Creating and indexing documents took: ${new Date().getTime() - startTime}ms`); } From a7743b365147fe3f7064fc02d20d835182d06f62 Mon Sep 17 00:00:00 2001 From: Katerina Patticha Date: Tue, 15 Feb 2022 19:14:51 +0100 Subject: [PATCH 21/39] [APM] Reduce maxNumServices from 500 to 50 (#125646) * [APM] Reduce maxNumServices from 500 to 50 * Fix snapshot --- .../server/routes/services/__snapshots__/queries.test.ts.snap | 4 ++-- .../server/routes/services/get_services/get_services_items.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/apm/server/routes/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/routes/services/__snapshots__/queries.test.ts.snap index df1b3954cfe29..0f7caab5bf6c0 100644 --- a/x-pack/plugins/apm/server/routes/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/routes/services/__snapshots__/queries.test.ts.snap @@ -161,7 +161,7 @@ Array [ }, "terms": Object { "field": "service.name", - "size": 500, + "size": 50, }, }, }, @@ -214,7 +214,7 @@ Array [ }, "terms": Object { "field": "service.name", - "size": 500, + "size": 50, }, }, }, diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts index 716fd82aefd46..c158f83ff5560 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts @@ -15,7 +15,7 @@ import { mergeServiceStats } from './merge_service_stats'; export type ServicesItemsSetup = Setup; -const MAX_NUMBER_OF_SERVICES = 500; +const MAX_NUMBER_OF_SERVICES = 50; export async function getServicesItems({ environment, From 07e645764ca5487bc61726eb1cfbc8f915aedc51 Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Tue, 15 Feb 2022 13:51:05 -0500 Subject: [PATCH 22/39] [Security Solution][Timeline] Use first event in threshold set for 'from' value in timeline (#123282) * Use first event in threshold set for 'from' value in timeline * lint * Linting and test fix * Tests * Fix signal generation tests * Update more 'from' dates in threshold tests * More test fixes Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../signals/executors/threshold.ts | 4 +++ .../bulk_create_threshold_signals.test.ts | 26 ++++++++++++------- .../bulk_create_threshold_signals.ts | 14 +++------- .../threshold/find_threshold_signals.test.ts | 25 ++++++++++++++++++ .../threshold/find_threshold_signals.ts | 5 ++++ .../lib/detection_engine/signals/types.ts | 1 + .../security_solution/server/lib/types.ts | 3 +++ .../tests/generating_signals.ts | 14 +++++----- .../tests/keyword_family/const_keyword.ts | 2 +- .../tests/keyword_family/keyword.ts | 2 +- .../keyword_mixed_with_const.ts | 2 +- 11 files changed, 68 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts index 34e6e26a30eab..441baa7ee94fd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts @@ -115,11 +115,13 @@ export const thresholdExecutor = async ({ index: ruleParams.index, }); + // Eliminate dupes const bucketFilters = await getThresholdBucketFilters({ signalHistory, timestampOverride: ruleParams.timestampOverride, }); + // Combine dupe filter with other filters const esFilter = await getFilter({ type: ruleParams.type, filters: ruleParams.filters ? ruleParams.filters.concat(bucketFilters) : bucketFilters, @@ -131,6 +133,7 @@ export const thresholdExecutor = async ({ lists: exceptionItems, }); + // Look for new events over threshold const { searchResult: thresholdResults, searchErrors, @@ -147,6 +150,7 @@ export const thresholdExecutor = async ({ buildRuleMessage, }); + // Build and index new alerts const { success, bulkCreateDuration, createdItemsCount, createdItems, errors } = await bulkCreateThresholdSignals({ someResult: thresholdResults, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts index 2d0907b045014..2c14e4bed62a8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts @@ -41,7 +41,10 @@ describe('transformThresholdNormalizedResultsToEcs', () => { key: 'garden-gnomes', doc_count: 12, max_timestamp: { - value_as_string: '2020-04-20T21:27:45+0000', + value_as_string: '2020-12-17T16:30:03.000Z', + }, + min_timestamp: { + value_as_string: '2020-12-17T16:28:03.000Z', }, cardinality_count: { value: 7, @@ -92,20 +95,20 @@ describe('transformThresholdNormalizedResultsToEcs', () => { _id, _index: 'test', _source: { - '@timestamp': '2020-04-20T21:27:45+0000', + '@timestamp': '2020-12-17T16:30:03.000Z', 'host.name': 'garden-gnomes', 'source.ip': '127.0.0.1', threshold_result: { - from: new Date('2020-12-17T16:28:00.000Z'), // from threshold signal history + from: new Date('2020-12-17T16:28:03.000Z'), // from min_timestamp terms: [ - { - field: 'host.name', - value: 'garden-gnomes', - }, { field: 'source.ip', value: '127.0.0.1', }, + { + field: 'host.name', + value: 'garden-gnomes', + }, ], cardinality: [ { @@ -207,7 +210,10 @@ describe('transformThresholdNormalizedResultsToEcs', () => { key: '', doc_count: 15, max_timestamp: { - value_as_string: '2020-04-20T21:27:45+0000', + value_as_string: '2020-12-17T16:30:03.000Z', + }, + min_timestamp: { + value_as_string: '2020-12-17T16:28:03.000Z', }, cardinality_count: { value: 7, @@ -250,9 +256,9 @@ describe('transformThresholdNormalizedResultsToEcs', () => { _id, _index: 'test', _source: { - '@timestamp': '2020-04-20T21:27:45+0000', + '@timestamp': '2020-12-17T16:30:03.000Z', threshold_result: { - from: new Date('2020-12-17T16:27:00.000Z'), + from: new Date('2020-12-17T16:28:03.000Z'), terms: [], cardinality: [ { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts index 1c2bdd0d70ced..f098f33b2ffc7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts @@ -22,11 +22,7 @@ import { import { BaseHit } from '../../../../../common/detection_engine/types'; import { TermAggregationBucket } from '../../../types'; import { GenericBulkCreateResponse } from '../bulk_create_factory'; -import { - calculateThresholdSignalUuid, - getThresholdAggregationParts, - getThresholdTermsHash, -} from '../utils'; +import { calculateThresholdSignalUuid, getThresholdAggregationParts } from '../utils'; import { buildReasonMessageForThresholdAlert } from '../reason_formatters'; import type { MultiAggBucket, @@ -102,6 +98,7 @@ const getTransformedHits = ( ].filter((term) => term.field != null), cardinality: val.cardinality, maxTimestamp: val.maxTimestamp, + minTimestamp: val.minTimestamp, docCount: val.docCount, }; acc.push(el as MultiAggBucket); @@ -124,6 +121,7 @@ const getTransformedHits = ( ] : undefined, maxTimestamp: bucket.max_timestamp.value_as_string, + minTimestamp: bucket.min_timestamp.value_as_string, docCount: bucket.doc_count, }; acc.push(el as MultiAggBucket); @@ -138,9 +136,6 @@ const getTransformedHits = ( 0, aggParts.field ).reduce((acc: Array>, bucket) => { - const termsHash = getThresholdTermsHash(bucket.terms); - const signalHit = signalHistory[termsHash]; - const source = { [TIMESTAMP]: bucket.maxTimestamp, ...bucket.terms.reduce((termAcc, term) => { @@ -162,8 +157,7 @@ const getTransformedHits = ( // threshold set in the timeline search. The upper bound will always be // the `original_time` of the signal (the timestamp of the latest event // in the set). - from: - signalHit?.lastSignalTimestamp != null ? new Date(signalHit.lastSignalTimestamp) : from, + from: new Date(bucket.minTimestamp) ?? from, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts index 5374ae53a74e8..3a1149e8c8e99 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts @@ -64,6 +64,11 @@ describe('findThresholdSignals', () => { field: '@timestamp', }, }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, }, }, }, @@ -101,6 +106,11 @@ describe('findThresholdSignals', () => { field: '@timestamp', }, }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, }, }, }, @@ -146,6 +156,11 @@ describe('findThresholdSignals', () => { field: '@timestamp', }, }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, }, }, }, @@ -212,6 +227,11 @@ describe('findThresholdSignals', () => { field: '@timestamp', }, }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, }, }, }, @@ -273,6 +293,11 @@ describe('findThresholdSignals', () => { field: '@timestamp', }, }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, }, }, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts index ad0ff99c019af..52aa429dd64d6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts @@ -56,6 +56,11 @@ export const findThresholdSignals = async ({ field: timestampOverride != null ? timestampOverride : TIMESTAMP, }, }, + min_timestamp: { + min: { + field: timestampOverride != null ? timestampOverride : TIMESTAMP, + }, + }, ...(threshold.cardinality?.length ? { cardinality_count: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 19e0c36bae052..37ed4a78a61a6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -361,6 +361,7 @@ export interface MultiAggBucket { }>; docCount: number; maxTimestamp: string; + minTimestamp: string; } export interface ThresholdQueryBucket extends TermAggregationBucket { diff --git a/x-pack/plugins/security_solution/server/lib/types.ts b/x-pack/plugins/security_solution/server/lib/types.ts index 15f40fdbc3019..919e9a7c7b160 100644 --- a/x-pack/plugins/security_solution/server/lib/types.ts +++ b/x-pack/plugins/security_solution/server/lib/types.ts @@ -74,6 +74,9 @@ export type SearchHit = SearchResponse['hits']['hits'][0]; export interface TermAggregationBucket { key: string; doc_count: number; + min_timestamp: { + value_as_string: string; + }; max_timestamp: { value_as_string: string; }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts index 6dd569d891fdc..5570eb2813c9b 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts @@ -738,7 +738,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], count: 788, - from: '1900-01-01T00:00:00.000Z', + from: '2019-02-19T07:12:05.332Z', }, }); }); @@ -865,7 +865,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], count: 788, - from: '1900-01-01T00:00:00.000Z', + from: '2019-02-19T07:12:05.332Z', }, }); }); @@ -920,10 +920,6 @@ export default ({ getService }: FtrProviderContext) => { [ALERT_DEPTH]: 1, [ALERT_THRESHOLD_RESULT]: { terms: [ - { - field: 'event.module', - value: 'system', - }, { field: 'host.id', value: '2ab45fc1c41e4c84bbd02202a7e5761f', @@ -932,9 +928,13 @@ export default ({ getService }: FtrProviderContext) => { field: 'process.name', value: 'sshd', }, + { + field: 'event.module', + value: 'system', + }, ], count: 21, - from: '1900-01-01T00:00:00.000Z', + from: '2019-02-19T20:22:03.561Z', }, }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts index 17492248f537a..7b3f938ceef2b 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts @@ -137,7 +137,7 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql([ { count: 4, - from: '1900-01-01T00:00:00.000Z', + from: '2020-10-27T05:00:53.000Z', terms: [ { field: 'event.dataset', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword.ts index 642b65f6a49c3..964bb40add2a1 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword.ts @@ -111,7 +111,7 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql([ { count: 4, - from: '1900-01-01T00:00:00.000Z', + from: '2020-10-28T05:00:53.000Z', terms: [ { field: 'event.dataset', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts index df158b239c120..c33354e383809 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts @@ -150,7 +150,7 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql([ { count: 8, - from: '1900-01-01T00:00:00.000Z', + from: '2020-10-27T05:00:53.000Z', terms: [ { field: 'event.dataset', From 7d186f945bd45ef6dfb51834a9a854546ab63ab4 Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Tue, 15 Feb 2022 13:51:44 -0500 Subject: [PATCH 23/39] [Security Solution] Upgrade tests for DE rule types - alerts on legacy alerts (#125331) * Add integration tests for alerts-on-legacy-alerts * Remove query rule tests from prior location - they were moved * Remove 'kibana' field from alerts on legacy alerts * Fix tests * Delete alerts before proceeding from compatibility tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../factories/utils/filter_source.ts | 2 + .../tests/alerts/alerts_compatibility.ts | 570 ++- .../tests/generating_signals.ts | 43 - .../detection_engine_api_integration/utils.ts | 56 +- .../legacy_cti_signals/mappings.json | 4495 ++++++++++++++++- 5 files changed, 4953 insertions(+), 213 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/filter_source.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/filter_source.ts index 35c91ba398f6f..473b0da1d58e9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/filter_source.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/filter_source.ts @@ -13,12 +13,14 @@ export const filterSource = (doc: SignalSourceHit): Partial => { const docSource = doc._source ?? {}; const { event, + kibana, signal, threshold_result: siemSignalsThresholdResult, [ALERT_THRESHOLD_RESULT]: alertThresholdResult, ...filteredSource } = docSource || { event: null, + kibana: null, signal: null, threshold_result: null, [ALERT_THRESHOLD_RESULT]: null, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/alerts/alerts_compatibility.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/alerts/alerts_compatibility.ts index a9942fc86566b..889396c2b6125 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/alerts/alerts_compatibility.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/alerts/alerts_compatibility.ts @@ -13,22 +13,54 @@ import { DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL, } from '../../../../../plugins/security_solution/common/constants'; import { + createRule, createSignalsIndex, + deleteAllAlerts, deleteSignalsIndex, finalizeSignalsMigration, + getEqlRuleForSignalTesting, + getRuleForSignalTesting, + getSavedQueryRuleForSignalTesting, + getSignalsByIds, + getThreatMatchRuleForSignalTesting, + getThresholdRuleForSignalTesting, startSignalsMigration, waitFor, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, } from '../../../utils'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { ThreatEcs } from '../../../../../plugins/security_solution/common/ecs/threat'; +import { + EqlCreateSchema, + QueryCreateSchema, + SavedQueryCreateSchema, + ThreatMatchCreateSchema, + ThresholdCreateSchema, +} from '../../../../../plugins/security_solution/common/detection_engine/schemas/request'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); - const supertest = getService('supertest'); const log = getService('log'); + const supertest = getService('supertest'); describe('Alerts Compatibility', function () { + beforeEach(async () => { + await esArchiver.load( + 'x-pack/test/functional/es_archives/security_solution/legacy_cti_signals' + ); + await createSignalsIndex(supertest, log); + }); + + afterEach(async () => { + await esArchiver.unload( + 'x-pack/test/functional/es_archives/security_solution/legacy_cti_signals' + ); + await deleteSignalsIndex(supertest, log); + await deleteAllAlerts(supertest, log); + }); + describe('CTI', () => { const expectedDomain = 'elastic.local'; const expectedProvider = 'provider1'; @@ -40,20 +72,6 @@ export default ({ getService }: FtrProviderContext) => { type: 'indicator_match_rule', }; - beforeEach(async () => { - await esArchiver.load( - 'x-pack/test/functional/es_archives/security_solution/legacy_cti_signals' - ); - await createSignalsIndex(supertest, log); - }); - - afterEach(async () => { - await esArchiver.unload( - 'x-pack/test/functional/es_archives/security_solution/legacy_cti_signals' - ); - await deleteSignalsIndex(supertest, log); - }); - it('allows querying of legacy enriched signals by threat.indicator', async () => { const { body: { @@ -161,6 +179,528 @@ export default ({ getService }: FtrProviderContext) => { ); expect(enrichmentProviders).to.eql([expectedProvider, expectedProvider]); }); + + it('should generate a signal-on-legacy-signal with legacy index pattern', async () => { + const rule: ThreatMatchCreateSchema = getThreatMatchRuleForSignalTesting([ + '.siem-signals-*', + ]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + + it('should generate a signal-on-legacy-signal with AAD index pattern', async () => { + const rule: ThreatMatchCreateSchema = getThreatMatchRuleForSignalTesting([ + `.alerts-security.alerts-default`, + ]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + }); + + describe('Query', () => { + it('should generate a signal-on-legacy-signal with legacy index pattern', async () => { + const rule: QueryCreateSchema = getRuleForSignalTesting([`.siem-signals-*`]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + const { + '@timestamp': timestamp, + 'kibana.version': kibanaVersion, + 'kibana.alert.rule.created_at': createdAt, + 'kibana.alert.rule.updated_at': updatedAt, + 'kibana.alert.rule.execution.uuid': executionUuid, + 'kibana.alert.uuid': alertId, + ...source + } = hit._source!; + expect(source).to.eql({ + 'kibana.alert.rule.category': 'Custom Query Rule', + 'kibana.alert.rule.consumer': 'siem', + 'kibana.alert.rule.name': 'Signal Testing Query', + 'kibana.alert.rule.producer': 'siem', + 'kibana.alert.rule.rule_type_id': 'siem.queryRule', + 'kibana.alert.rule.uuid': id, + 'kibana.space_ids': ['default'], + 'kibana.alert.rule.tags': [], + agent: { + ephemeral_id: '07c24b1e-3663-4372-b982-f2d831e033eb', + hostname: 'elastic.local', + id: 'ce7741d9-3f0a-466d-8ae6-d7d8f883fcec', + name: 'elastic.local', + type: 'auditbeat', + version: '7.14.0', + }, + ecs: { version: '1.10.0' }, + host: { + architecture: 'x86_64', + hostname: 'elastic.local', + id: '1633D595-A115-5BF5-870B-A471B49446C3', + ip: ['192.168.1.1'], + mac: ['aa:bb:cc:dd:ee:ff'], + name: 'elastic.local', + os: { + build: '20G80', + family: 'darwin', + kernel: '20.6.0', + name: 'Mac OS X', + platform: 'darwin', + type: 'macos', + version: '10.16', + }, + }, + message: 'Process mdworker_shared (PID: 32306) by user elastic STARTED', + process: { + args: [ + '/System/Library/Frameworks/CoreServices.framework/Frameworks/Metadata.framework/Versions/A/Support/mdworker_shared', + '-s', + 'mdworker', + '-c', + 'MDSImporterWorker', + '-m', + 'com.apple.mdworker.shared', + ], + entity_id: 'wfc7zUuEinqxUbZ6', + executable: + '/System/Library/Frameworks/CoreServices.framework/Frameworks/Metadata.framework/Versions/A/Support/mdworker_shared', + hash: { sha1: '5f3233fd75c14b315731684d59b632df36a731a6' }, + name: 'mdworker_shared', + pid: 32306, + ppid: 1, + start: '2021-08-04T04:14:48.830Z', + working_directory: '/', + }, + service: { type: 'system' }, + threat: { + indicator: [ + { + domain: 'elastic.local', + event: { + category: 'threat', + created: '2021-08-04T03:53:30.761Z', + dataset: 'ti_abusech.malware', + ingested: '2021-08-04T03:53:37.514040Z', + kind: 'enrichment', + module: 'threatintel', + reference: 'https://urlhaus.abuse.ch/url/12345/', + type: 'indicator', + }, + first_seen: '2021-08-03T20:35:17.000Z', + matched: { + atomic: 'elastic.local', + field: 'host.name', + id: '_tdUD3sBcVT20cvWAkpd', + index: 'filebeat-7.14.0-2021.08.04-000001', + type: 'indicator_match_rule', + }, + provider: 'provider1', + type: 'url', + url: { + domain: 'elastic.local', + extension: 'php', + full: 'http://elastic.local/thing', + original: 'http://elastic.local/thing', + path: '/thing', + scheme: 'http', + }, + }, + ], + }, + user: { + effective: { group: { id: '20' }, id: '501' }, + group: { id: '20', name: 'staff' }, + id: '501', + name: 'elastic', + saved: { group: { id: '20' }, id: '501' }, + }, + 'event.action': 'process_started', + 'event.category': ['process'], + 'event.dataset': 'process', + 'event.kind': 'signal', + 'event.module': 'system', + 'event.type': ['start'], + 'kibana.alert.ancestors': [ + { + depth: 0, + id: 'yNdfD3sBcVT20cvWFEs2', + index: 'auditbeat-7.14.0-2021.08.04-000001', + type: 'event', + }, + { + id: '0527411874b23bcea85daf5bf7dcacd144536ba6d92d3230a4a0acfb7de7f512', + type: 'signal', + index: '.siem-signals-default-000001', + depth: 1, + rule: '832f86f0-f4da-11eb-989d-b758d09dbc85', + }, + ], + 'kibana.alert.status': 'active', + 'kibana.alert.workflow_status': 'open', + 'kibana.alert.depth': 2, + 'kibana.alert.reason': + 'process event with process mdworker_shared, by elastic on elastic.local created high alert Signal Testing Query.', + 'kibana.alert.severity': 'high', + 'kibana.alert.risk_score': 1, + 'kibana.alert.rule.parameters': { + description: 'Tests a simple query', + risk_score: 1, + severity: 'high', + author: [], + false_positives: [], + from: '1900-01-01T00:00:00.000Z', + rule_id: 'rule-1', + max_signals: 100, + risk_score_mapping: [], + severity_mapping: [], + threat: [], + to: 'now', + references: [], + version: 1, + exceptions_list: [], + immutable: false, + type: 'query', + language: 'kuery', + index: ['.siem-signals-*'], + query: '*:*', + }, + 'kibana.alert.rule.actions': [], + 'kibana.alert.rule.created_by': 'elastic', + 'kibana.alert.rule.enabled': true, + 'kibana.alert.rule.interval': '5m', + 'kibana.alert.rule.updated_by': 'elastic', + 'kibana.alert.rule.type': 'query', + 'kibana.alert.rule.description': 'Tests a simple query', + 'kibana.alert.rule.risk_score': 1, + 'kibana.alert.rule.severity': 'high', + 'kibana.alert.rule.author': [], + 'kibana.alert.rule.false_positives': [], + 'kibana.alert.rule.from': '1900-01-01T00:00:00.000Z', + 'kibana.alert.rule.rule_id': 'rule-1', + 'kibana.alert.rule.max_signals': 100, + 'kibana.alert.rule.risk_score_mapping': [], + 'kibana.alert.rule.severity_mapping': [], + 'kibana.alert.rule.threat': [], + 'kibana.alert.rule.to': 'now', + 'kibana.alert.rule.references': [], + 'kibana.alert.rule.version': 1, + 'kibana.alert.rule.exceptions_list': [], + 'kibana.alert.rule.immutable': false, + 'kibana.alert.original_time': '2021-08-04T04:14:58.973Z', + 'kibana.alert.original_event.action': 'process_started', + 'kibana.alert.original_event.category': ['process'], + 'kibana.alert.original_event.dataset': 'process', + 'kibana.alert.original_event.kind': 'signal', + 'kibana.alert.original_event.module': 'system', + 'kibana.alert.original_event.type': ['start'], + }); + }); + + it('should generate a signal-on-legacy-signal with AAD index pattern', async () => { + const rule: QueryCreateSchema = getRuleForSignalTesting([ + `.alerts-security.alerts-default`, + ]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + const { + '@timestamp': timestamp, + 'kibana.version': kibanaVersion, + 'kibana.alert.rule.created_at': createdAt, + 'kibana.alert.rule.updated_at': updatedAt, + 'kibana.alert.rule.execution.uuid': executionUuid, + 'kibana.alert.uuid': alertId, + ...source + } = hit._source!; + expect(source).to.eql({ + 'kibana.alert.rule.category': 'Custom Query Rule', + 'kibana.alert.rule.consumer': 'siem', + 'kibana.alert.rule.name': 'Signal Testing Query', + 'kibana.alert.rule.producer': 'siem', + 'kibana.alert.rule.rule_type_id': 'siem.queryRule', + 'kibana.alert.rule.uuid': id, + 'kibana.space_ids': ['default'], + 'kibana.alert.rule.tags': [], + agent: { + ephemeral_id: '07c24b1e-3663-4372-b982-f2d831e033eb', + hostname: 'elastic.local', + id: 'ce7741d9-3f0a-466d-8ae6-d7d8f883fcec', + name: 'elastic.local', + type: 'auditbeat', + version: '7.14.0', + }, + ecs: { version: '1.10.0' }, + host: { + architecture: 'x86_64', + hostname: 'elastic.local', + id: '1633D595-A115-5BF5-870B-A471B49446C3', + ip: ['192.168.1.1'], + mac: ['aa:bb:cc:dd:ee:ff'], + name: 'elastic.local', + os: { + build: '20G80', + family: 'darwin', + kernel: '20.6.0', + name: 'Mac OS X', + platform: 'darwin', + type: 'macos', + version: '10.16', + }, + }, + message: 'Process mdworker_shared (PID: 32306) by user elastic STARTED', + process: { + args: [ + '/System/Library/Frameworks/CoreServices.framework/Frameworks/Metadata.framework/Versions/A/Support/mdworker_shared', + '-s', + 'mdworker', + '-c', + 'MDSImporterWorker', + '-m', + 'com.apple.mdworker.shared', + ], + entity_id: 'wfc7zUuEinqxUbZ6', + executable: + '/System/Library/Frameworks/CoreServices.framework/Frameworks/Metadata.framework/Versions/A/Support/mdworker_shared', + hash: { sha1: '5f3233fd75c14b315731684d59b632df36a731a6' }, + name: 'mdworker_shared', + pid: 32306, + ppid: 1, + start: '2021-08-04T04:14:48.830Z', + working_directory: '/', + }, + service: { type: 'system' }, + threat: { + indicator: [ + { + domain: 'elastic.local', + event: { + category: 'threat', + created: '2021-08-04T03:53:30.761Z', + dataset: 'ti_abusech.malware', + ingested: '2021-08-04T03:53:37.514040Z', + kind: 'enrichment', + module: 'threatintel', + reference: 'https://urlhaus.abuse.ch/url/12345/', + type: 'indicator', + }, + first_seen: '2021-08-03T20:35:17.000Z', + matched: { + atomic: 'elastic.local', + field: 'host.name', + id: '_tdUD3sBcVT20cvWAkpd', + index: 'filebeat-7.14.0-2021.08.04-000001', + type: 'indicator_match_rule', + }, + provider: 'provider1', + type: 'url', + url: { + domain: 'elastic.local', + extension: 'php', + full: 'http://elastic.local/thing', + original: 'http://elastic.local/thing', + path: '/thing', + scheme: 'http', + }, + }, + ], + }, + user: { + effective: { group: { id: '20' }, id: '501' }, + group: { id: '20', name: 'staff' }, + id: '501', + name: 'elastic', + saved: { group: { id: '20' }, id: '501' }, + }, + 'event.action': 'process_started', + 'event.category': ['process'], + 'event.dataset': 'process', + 'event.kind': 'signal', + 'event.module': 'system', + 'event.type': ['start'], + 'kibana.alert.ancestors': [ + { + depth: 0, + id: 'yNdfD3sBcVT20cvWFEs2', + index: 'auditbeat-7.14.0-2021.08.04-000001', + type: 'event', + }, + { + id: '0527411874b23bcea85daf5bf7dcacd144536ba6d92d3230a4a0acfb7de7f512', + type: 'signal', + index: '.siem-signals-default-000001', + depth: 1, + rule: '832f86f0-f4da-11eb-989d-b758d09dbc85', + }, + ], + 'kibana.alert.status': 'active', + 'kibana.alert.workflow_status': 'open', + 'kibana.alert.depth': 2, + 'kibana.alert.reason': + 'process event with process mdworker_shared, by elastic on elastic.local created high alert Signal Testing Query.', + 'kibana.alert.severity': 'high', + 'kibana.alert.risk_score': 1, + 'kibana.alert.rule.parameters': { + description: 'Tests a simple query', + risk_score: 1, + severity: 'high', + author: [], + false_positives: [], + from: '1900-01-01T00:00:00.000Z', + rule_id: 'rule-1', + max_signals: 100, + risk_score_mapping: [], + severity_mapping: [], + threat: [], + to: 'now', + references: [], + version: 1, + exceptions_list: [], + immutable: false, + type: 'query', + language: 'kuery', + index: ['.alerts-security.alerts-default'], + query: '*:*', + }, + 'kibana.alert.rule.actions': [], + 'kibana.alert.rule.created_by': 'elastic', + 'kibana.alert.rule.enabled': true, + 'kibana.alert.rule.interval': '5m', + 'kibana.alert.rule.updated_by': 'elastic', + 'kibana.alert.rule.type': 'query', + 'kibana.alert.rule.description': 'Tests a simple query', + 'kibana.alert.rule.risk_score': 1, + 'kibana.alert.rule.severity': 'high', + 'kibana.alert.rule.author': [], + 'kibana.alert.rule.false_positives': [], + 'kibana.alert.rule.from': '1900-01-01T00:00:00.000Z', + 'kibana.alert.rule.rule_id': 'rule-1', + 'kibana.alert.rule.max_signals': 100, + 'kibana.alert.rule.risk_score_mapping': [], + 'kibana.alert.rule.severity_mapping': [], + 'kibana.alert.rule.threat': [], + 'kibana.alert.rule.to': 'now', + 'kibana.alert.rule.references': [], + 'kibana.alert.rule.version': 1, + 'kibana.alert.rule.exceptions_list': [], + 'kibana.alert.rule.immutable': false, + 'kibana.alert.original_time': '2021-08-04T04:14:58.973Z', + 'kibana.alert.original_event.action': 'process_started', + 'kibana.alert.original_event.category': ['process'], + 'kibana.alert.original_event.dataset': 'process', + 'kibana.alert.original_event.kind': 'signal', + 'kibana.alert.original_event.module': 'system', + 'kibana.alert.original_event.type': ['start'], + }); + }); + }); + + describe('Saved Query', () => { + it('should generate a signal-on-legacy-signal with legacy index pattern', async () => { + const rule: SavedQueryCreateSchema = getSavedQueryRuleForSignalTesting([`.siem-signals-*`]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + + it('should generate a signal-on-legacy-signal with AAD index pattern', async () => { + const rule: SavedQueryCreateSchema = getSavedQueryRuleForSignalTesting([ + `.alerts-security.alerts-default`, + ]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + }); + + describe('EQL', () => { + it('should generate a signal-on-legacy-signal with legacy index pattern', async () => { + const rule: EqlCreateSchema = getEqlRuleForSignalTesting(['.siem-signals-*']); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + + it('should generate a signal-on-legacy-signal with AAD index pattern', async () => { + const rule: EqlCreateSchema = getEqlRuleForSignalTesting([ + `.alerts-security.alerts-default`, + ]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + }); + + describe('Threshold', () => { + it('should generate a signal-on-legacy-signal with legacy index pattern', async () => { + const baseRule: ThresholdCreateSchema = getThresholdRuleForSignalTesting([ + '.siem-signals-*', + ]); + const rule: ThresholdCreateSchema = { + ...baseRule, + threshold: { + ...baseRule.threshold, + value: 1, + }, + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + + it('should generate a signal-on-legacy-signal with AAD index pattern', async () => { + const baseRule: ThresholdCreateSchema = getThresholdRuleForSignalTesting([ + `.alerts-security.alerts-default`, + ]); + const rule: ThresholdCreateSchema = { + ...baseRule, + threshold: { + ...baseRule.threshold, + value: 1, + }, + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); }); }); }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts index 5570eb2813c9b..f9c4a1bac9d24 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts @@ -941,49 +941,6 @@ export default ({ getService }: FtrProviderContext) => { }); }); - /** - * Here we test that 8.0.x alerts can be generated on legacy (pre-8.x) alerts. - */ - describe('Signals generated from legacy signals', async () => { - beforeEach(async () => { - await deleteSignalsIndex(supertest, log); - await createSignalsIndex(supertest, log); - await esArchiver.load( - 'x-pack/test/functional/es_archives/security_solution/legacy_cti_signals' - ); - }); - - afterEach(async () => { - await esArchiver.unload( - 'x-pack/test/functional/es_archives/security_solution/legacy_cti_signals' - ); - await deleteSignalsIndex(supertest, log); - await deleteAllAlerts(supertest, log); - }); - - it('should generate a signal-on-legacy-signal with legacy index pattern', async () => { - const rule: QueryCreateSchema = { - ...getRuleForSignalTesting([`.siem-signals-*`]), - }; - const { id } = await createRule(supertest, log, rule); - await waitForRuleSuccessOrStatus(supertest, log, id); - await waitForSignalsToBePresent(supertest, log, 1, [id]); - const signalsOpen = await getSignalsByIds(supertest, log, [id]); - expect(signalsOpen.hits.hits.length).greaterThan(0); - }); - - it('should generate a signal-on-legacy-signal with AAD index pattern', async () => { - const rule: QueryCreateSchema = { - ...getRuleForSignalTesting([`.alerts-security.alerts-default`]), - }; - const { id } = await createRule(supertest, log, rule); - await waitForRuleSuccessOrStatus(supertest, log, id); - await waitForSignalsToBePresent(supertest, log, 1, [id]); - const signalsOpen = await getSignalsByIds(supertest, log, [id]); - expect(signalsOpen.hits.hits.length).greaterThan(0); - }); - }); - /** * Here we test the functionality of Severity and Risk Score overrides (also called "mappings" * in the code). If the rule specifies a mapping, then the final Severity or Risk Score diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 8f05a7fd94487..9cbaef3ad0fe2 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -36,6 +36,7 @@ import { PreviewRulesSchema, ThreatMatchCreateSchema, RulePreviewLogs, + SavedQueryCreateSchema, } from '../../plugins/security_solution/common/detection_engine/schemas/request'; import { signalsMigrationType } from '../../plugins/security_solution/server/lib/detection_engine/migrations/saved_objects'; import { @@ -131,7 +132,7 @@ export const getSimplePreviewRule = ( /** * This is a typical signal testing rule that is easy for most basic testing of output of signals. - * It starts out in an enabled true state. The from is set very far back to test the basics of signal + * It starts out in an enabled true state. The 'from' is set very far back to test the basics of signal * creation and testing by getting all the signals at once. * @param ruleId The optional ruleId which is rule-1 by default. * @param enabled Enables the rule on creation or not. Defaulted to true. @@ -153,9 +154,26 @@ export const getRuleForSignalTesting = ( from: '1900-01-01T00:00:00.000Z', }); +/** + * This is a typical signal testing rule that is easy for most basic testing of output of Saved Query signals. + * It starts out in an enabled true state. The 'from' is set very far back to test the basics of signal + * creation for SavedQuery and testing by getting all the signals at once. + * @param ruleId The optional ruleId which is threshold-rule by default. + * @param enabled Enables the rule on creation or not. Defaulted to true. + */ +export const getSavedQueryRuleForSignalTesting = ( + index: string[], + ruleId = 'saved-query-rule', + enabled = true +): SavedQueryCreateSchema => ({ + ...getRuleForSignalTesting(index, ruleId, enabled), + type: 'saved_query', + saved_id: 'abcd', +}); + /** * This is a typical signal testing rule that is easy for most basic testing of output of EQL signals. - * It starts out in an enabled true state. The from is set very far back to test the basics of signal + * It starts out in an enabled true state. The 'from' is set very far back to test the basics of signal * creation for EQL and testing by getting all the signals at once. * @param ruleId The optional ruleId which is eql-rule by default. * @param enabled Enables the rule on creation or not. Defaulted to true. @@ -171,9 +189,41 @@ export const getEqlRuleForSignalTesting = ( query: 'any where true', }); +/** + * This is a typical signal testing rule that is easy for most basic testing of output of Threat Match signals. + * It starts out in an enabled true state. The 'from' is set very far back to test the basics of signal + * creation for Threat Match and testing by getting all the signals at once. + * @param ruleId The optional ruleId which is threshold-rule by default. + * @param enabled Enables the rule on creation or not. Defaulted to true. + */ +export const getThreatMatchRuleForSignalTesting = ( + index: string[], + ruleId = 'threat-match-rule', + enabled = true +): ThreatMatchCreateSchema => ({ + ...getRuleForSignalTesting(index, ruleId, enabled), + type: 'threat_match', + language: 'kuery', + query: '*:*', + threat_query: '*:*', + threat_mapping: [ + // We match host.name against host.name + { + entries: [ + { + field: 'host.name', + value: 'host.name', + type: 'mapping', + }, + ], + }, + ], + threat_index: index, // match against same index for simplicity +}); + /** * This is a typical signal testing rule that is easy for most basic testing of output of Threshold signals. - * It starts out in an enabled true state. The from is set very far back to test the basics of signal + * It starts out in an enabled true state. The 'from' is set very far back to test the basics of signal * creation for Threshold and testing by getting all the signals at once. * @param ruleId The optional ruleId which is threshold-rule by default. * @param enabled Enables the rule on creation or not. Defaulted to true. diff --git a/x-pack/test/functional/es_archives/security_solution/legacy_cti_signals/mappings.json b/x-pack/test/functional/es_archives/security_solution/legacy_cti_signals/mappings.json index feda6d3ac85b9..87f3b6d570e80 100644 --- a/x-pack/test/functional/es_archives/security_solution/legacy_cti_signals/mappings.json +++ b/x-pack/test/functional/es_archives/security_solution/legacy_cti_signals/mappings.json @@ -20,13 +20,3383 @@ "@timestamp": { "type": "date" }, + "agent": { + "properties": { + "build": { + "properties": { + "original": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ephemeral_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + } + } + }, + "client": { + "properties": { + "address": { + "type": "keyword", + "ignore_above": 1024 + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "postal_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "subdomain": { + "type": "keyword", + "ignore_above": 1024 + }, + "top_level_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "user": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "availability_zone": { + "type": "keyword", + "ignore_above": 1024 + }, + "instance": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "machine": { + "properties": { + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "project": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "provider": { + "type": "keyword", + "ignore_above": 1024 + }, + "region": { + "type": "keyword", + "ignore_above": 1024 + }, + "service": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "container": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "image": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "tag": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "runtime": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "data_stream": { + "properties": { + "dataset": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "type": "keyword", + "ignore_above": 1024 + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "postal_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "subdomain": { + "type": "keyword", + "ignore_above": 1024 + }, + "top_level_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "user": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "dll": { + "properties": { + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "signing_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "team_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512": { + "type": "keyword", + "ignore_above": 1024 + }, + "ssdeep": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "pe": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "company": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "file_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "imphash": { + "type": "keyword", + "ignore_above": 1024 + }, + "original_file_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "product": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "dns": { + "properties": { + "answers": { + "properties": { + "class": { + "type": "keyword", + "ignore_above": 1024 + }, + "data": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "ttl": { + "type": "long" + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "header_flags": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "op_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "question": { + "properties": { + "class": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "registered_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "subdomain": { + "type": "keyword", + "ignore_above": 1024 + }, + "top_level_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ecs": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "error": { + "properties": { + "code": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "message": { + "type": "text", + "norms": false + }, + "stack_trace": { + "type": "keyword", + "index": false, + "doc_values": false, + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "event": { + "properties": { + "action": { + "type": "keyword", + "ignore_above": 1024 + }, + "category": { + "type": "keyword", + "ignore_above": 1024 + }, + "code": { + "type": "keyword", + "ignore_above": 1024 + }, + "created": { + "type": "date" + }, + "dataset": { + "type": "keyword", + "ignore_above": 1024 + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "ingested": { + "type": "date" + }, + "kind": { + "type": "keyword", + "ignore_above": 1024 + }, + "module": { + "type": "keyword", + "ignore_above": 1024 + }, + "original": { + "type": "keyword", + "index": false, + "doc_values": false, + "ignore_above": 1024 + }, + "outcome": { + "type": "keyword", + "ignore_above": 1024 + }, + "provider": { + "type": "keyword", + "ignore_above": 1024 + }, + "reason": { + "type": "keyword", + "ignore_above": 1024 + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "url": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "type": "keyword", + "ignore_above": 1024 + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "signing_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "team_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "type": "keyword", + "ignore_above": 1024 + }, + "directory": { + "type": "keyword", + "ignore_above": 1024 + }, + "drive_letter": { + "type": "keyword", + "ignore_above": 1 + }, + "extension": { + "type": "keyword", + "ignore_above": 1024 + }, + "gid": { + "type": "keyword", + "ignore_above": 1024 + }, + "group": { + "type": "keyword", + "ignore_above": 1024 + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512": { + "type": "keyword", + "ignore_above": 1024 + }, + "ssdeep": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "inode": { + "type": "keyword", + "ignore_above": 1024 + }, + "mime_type": { + "type": "keyword", + "ignore_above": 1024 + }, + "mode": { + "type": "keyword", + "ignore_above": 1024 + }, + "mtime": { + "type": "date" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "owner": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "pe": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "company": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "file_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "imphash": { + "type": "keyword", + "ignore_above": 1024 + }, + "original_file_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "product": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "size": { + "type": "long" + }, + "target_path": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "uid": { + "type": "keyword", + "ignore_above": 1024 + }, + "x509": { + "properties": { + "alternative_names": { + "type": "keyword", + "ignore_above": 1024 + }, + "issuer": { + "properties": { + "common_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country": { + "type": "keyword", + "ignore_above": 1024 + }, + "distinguished_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "locality": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "organizational_unit": { + "type": "keyword", + "ignore_above": 1024 + }, + "state_or_province": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "type": "keyword", + "ignore_above": 1024 + }, + "public_key_curve": { + "type": "keyword", + "ignore_above": 1024 + }, + "public_key_exponent": { + "type": "long", + "index": false, + "doc_values": false + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "signature_algorithm": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject": { + "properties": { + "common_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country": { + "type": "keyword", + "ignore_above": 1024 + }, + "distinguished_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "locality": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "organizational_unit": { + "type": "keyword", + "ignore_above": 1024 + }, + "state_or_province": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "version_number": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "host": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "cpu": { + "properties": { + "usage": { + "type": "scaled_float", + "scaling_factor": 1000.0 + } + } + }, + "disk": { + "properties": { + "read": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "write": { + "properties": { + "bytes": { + "type": "long" + } + } + } + } + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "postal_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hostname": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "network": { + "properties": { + "egress": { + "properties": { + "bytes": { + "type": "long" + }, + "packets": { + "type": "long" + } + } + }, + "ingress": { + "properties": { + "bytes": { + "type": "long" + }, + "packets": { + "type": "long" + } + } + } + } + }, + "os": { + "properties": { + "family": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "kernel": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "platform": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "method": { + "type": "keyword", + "ignore_above": 1024 + }, + "mime_type": { + "type": "keyword", + "ignore_above": 1024 + }, + "referrer": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "mime_type": { + "type": "keyword", + "ignore_above": 1024 + }, + "status_code": { + "type": "long" + } + } + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "interface": { + "properties": { + "alias": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "file": { + "properties": { + "path": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "level": { + "type": "keyword", + "ignore_above": 1024 + }, + "logger": { + "type": "keyword", + "ignore_above": 1024 + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "integer" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "function": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "original": { + "type": "keyword", + "index": false, + "doc_values": false, + "ignore_above": 1024 + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } + } + }, + "message": { + "type": "text", + "norms": false + }, + "network": { + "properties": { + "application": { + "type": "keyword", + "ignore_above": 1024 + }, + "bytes": { + "type": "long" + }, + "community_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "direction": { + "type": "keyword", + "ignore_above": 1024 + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "inner": { + "properties": { + "vlan": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "packets": { + "type": "long" + }, + "protocol": { + "type": "keyword", + "ignore_above": 1024 + }, + "transport": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "vlan": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "observer": { + "properties": { + "egress": { + "properties": { + "interface": { + "properties": { + "alias": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "vlan": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "zone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "postal_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hostname": { + "type": "keyword", + "ignore_above": 1024 + }, + "ingress": { + "properties": { + "interface": { + "properties": { + "alias": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "vlan": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "zone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "os": { + "properties": { + "family": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "kernel": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "platform": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "product": { + "type": "keyword", + "ignore_above": 1024 + }, + "serial_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "vendor": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "orchestrator": { + "properties": { + "api_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "cluster": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "url": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "namespace": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "resource": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "organization": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + }, + "os": { + "properties": { + "family": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "kernel": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "platform": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "package": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "build_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "checksum": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "install_scope": { + "type": "keyword", + "ignore_above": 1024 + }, + "installed": { + "type": "date" + }, + "license": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + }, + "size": { + "type": "long" + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "pe": { + "properties": { + "company": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "file_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "original_file_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "product": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "process": { + "properties": { + "args": { + "type": "keyword", + "ignore_above": 1024 + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "signing_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "team_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "entity_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "executable": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512": { + "type": "keyword", + "ignore_above": 1024 + }, + "ssdeep": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "parent": { + "properties": { + "args": { + "type": "keyword", + "ignore_above": 1024 + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "signing_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "team_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "entity_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "executable": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512": { + "type": "keyword", + "ignore_above": 1024 + }, + "ssdeep": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "pe": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "company": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "file_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "imphash": { + "type": "keyword", + "ignore_above": 1024 + }, + "original_file_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "product": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "title": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + }, + "pe": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "company": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "file_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "imphash": { + "type": "keyword", + "ignore_above": 1024 + }, + "original_file_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "product": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "title": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "type": "keyword", + "ignore_above": 1024 + }, + "strings": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hive": { + "type": "keyword", + "ignore_above": 1024 + }, + "key": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "value": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "related": { + "properties": { + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "hosts": { + "type": "keyword", + "ignore_above": 1024 + }, + "ip": { + "type": "ip" + }, + "user": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "rule": { + "properties": { + "author": { + "type": "keyword", + "ignore_above": 1024 + }, + "category": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "license": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + }, + "ruleset": { + "type": "keyword", + "ignore_above": 1024 + }, + "uuid": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "server": { + "properties": { + "address": { + "type": "keyword", + "ignore_above": 1024 + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "postal_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "subdomain": { + "type": "keyword", + "ignore_above": 1024 + }, + "top_level_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "user": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "node": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "state": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "signal": { + "properties": { + "_meta": { + "properties": { + "version": { + "type": "long" + } + } + }, + "ancestors": { + "properties": { + "depth": { + "type": "long" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "rule": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "depth": { + "type": "integer" + }, + "group": { + "properties": { + "id": { + "type": "keyword" + }, + "index": { + "type": "integer" + } + } + }, + "original_event": { + "properties": { + "action": { + "type": "keyword" + }, + "category": { + "type": "keyword" + }, + "code": { + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "kind": { + "type": "keyword" + }, + "module": { + "type": "keyword" + }, + "original": { + "type": "keyword", + "index": false, + "doc_values": false + }, + "outcome": { + "type": "keyword" + }, + "provider": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "original_signal": { + "type": "object", + "dynamic": "false", + "enabled": false + }, + "original_time": { + "type": "date" + }, + "parent": { + "properties": { + "depth": { + "type": "long" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "rule": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "parents": { + "properties": { + "depth": { + "type": "long" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "rule": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "author": { + "type": "keyword" + }, + "building_block_type": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "enabled": { + "type": "keyword" + }, + "false_positives": { + "type": "keyword" + }, + "filters": { + "type": "object" + }, + "from": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "immutable": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "interval": { + "type": "keyword" + }, + "language": { + "type": "keyword" + }, + "license": { + "type": "keyword" + }, + "max_signals": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "output_index": { + "type": "keyword" + }, + "query": { + "type": "keyword" + }, + "references": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_mapping": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + } + }, + "rule_id": { + "type": "keyword" + }, + "rule_name_override": { + "type": "keyword" + }, + "saved_id": { + "type": "keyword" + }, + "severity": { + "type": "keyword" + }, + "severity_mapping": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "severity": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + } + }, + "size": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "reference": { + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "reference": { + "type": "keyword" + }, + "subtechnique": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "reference": { + "type": "keyword" + } + } + } + } + } + } + }, + "threat_filters": { + "type": "object" + }, + "threat_index": { + "type": "keyword" + }, + "threat_indicator_path": { + "type": "keyword" + }, + "threat_language": { + "type": "keyword" + }, + "threat_mapping": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + } + } + } + }, + "threat_query": { + "type": "keyword" + }, + "threshold": { + "properties": { + "field": { + "type": "keyword" + }, + "value": { + "type": "float" + } + } + }, + "timeline_id": { + "type": "keyword" + }, + "timeline_title": { + "type": "keyword" + }, + "timestamp_override": { + "type": "keyword" + }, + "to": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "status": { + "type": "keyword" + }, + "threshold_count": { + "type": "float" + }, + "threshold_result": { + "properties": { + "cardinality": { + "properties": { + "field": { + "type": "keyword" + }, + "value": { + "type": "long" + } + } + }, + "count": { + "type": "long" + }, + "from": { + "type": "date" + }, + "terms": { + "properties": { + "field": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + } + } + } + } + } + }, + "source": { + "properties": { + "address": { + "type": "keyword", + "ignore_above": 1024 + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "postal_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "subdomain": { + "type": "keyword", + "ignore_above": 1024 + }, + "top_level_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "user": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "span": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "tags": { + "type": "keyword", + "ignore_above": 1024 + }, "threat": { "properties": { "framework": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "indicator": { + "type": "nested", "properties": { "as": { "properties": { @@ -36,62 +3406,62 @@ "organization": { "properties": { "name": { + "type": "keyword", + "ignore_above": 1024, "fields": { "text": { - "norms": false, - "type": "text" + "type": "text", + "norms": false } - }, - "ignore_above": 1024, - "type": "keyword" + } } } } } }, "confidence": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "dataset": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "description": { "type": "wildcard" }, "domain": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "email": { "properties": { "address": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 } } }, "event": { "properties": { "action": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "category": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "code": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "created": { "type": "date" }, "dataset": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "duration": { "type": "long" @@ -100,45 +3470,45 @@ "type": "date" }, "hash": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "id": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "ingested": { "type": "date" }, "kind": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "module": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "original": { - "doc_values": false, - "ignore_above": 1024, + "type": "keyword", "index": false, - "type": "keyword" + "doc_values": false, + "ignore_above": 1024 }, "outcome": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "provider": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "reason": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "reference": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "risk_score": { "type": "float" @@ -156,170 +3526,991 @@ "type": "date" }, "timezone": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "type": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "url": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "first_seen": { + "type": "date" + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "last_seen": { + "type": "date" + }, + "marking": { + "properties": { + "tlp": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "matched": { + "properties": { + "atomic": { + "type": "keyword", + "ignore_above": 1024 + }, + "field": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "module": { + "type": "keyword", + "ignore_above": 1024 + }, + "port": { + "type": "long" + }, + "provider": { + "type": "keyword", + "ignore_above": 1024 + }, + "scanner_stats": { + "type": "long" + }, + "sightings": { + "type": "long" + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "tactic": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "technique": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + }, + "subtechnique": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", "ignore_above": 1024, - "type": "keyword" + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } + } + }, + "tls": { + "properties": { + "cipher": { + "type": "keyword", + "ignore_above": 1024 + }, + "client": { + "properties": { + "certificate": { + "type": "keyword", + "ignore_above": 1024 + }, + "certificate_chain": { + "type": "keyword", + "ignore_above": 1024 + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "issuer": { + "type": "keyword", + "ignore_above": 1024 + }, + "ja3": { + "type": "keyword", + "ignore_above": 1024 + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject": { + "type": "keyword", + "ignore_above": 1024 + }, + "supported_ciphers": { + "type": "keyword", + "ignore_above": 1024 + }, + "x509": { + "properties": { + "alternative_names": { + "type": "keyword", + "ignore_above": 1024 + }, + "issuer": { + "properties": { + "common_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country": { + "type": "keyword", + "ignore_above": 1024 + }, + "distinguished_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "locality": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "organizational_unit": { + "type": "keyword", + "ignore_above": 1024 + }, + "state_or_province": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "type": "keyword", + "ignore_above": 1024 + }, + "public_key_curve": { + "type": "keyword", + "ignore_above": 1024 + }, + "public_key_exponent": { + "type": "long", + "index": false, + "doc_values": false + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "signature_algorithm": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject": { + "properties": { + "common_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country": { + "type": "keyword", + "ignore_above": 1024 + }, + "distinguished_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "locality": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "organizational_unit": { + "type": "keyword", + "ignore_above": 1024 + }, + "state_or_province": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "version_number": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "curve": { + "type": "keyword", + "ignore_above": 1024 + }, + "established": { + "type": "boolean" + }, + "next_protocol": { + "type": "keyword", + "ignore_above": 1024 + }, + "resumed": { + "type": "boolean" + }, + "server": { + "properties": { + "certificate": { + "type": "keyword", + "ignore_above": 1024 + }, + "certificate_chain": { + "type": "keyword", + "ignore_above": 1024 + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "issuer": { + "type": "keyword", + "ignore_above": 1024 + }, + "ja3s": { + "type": "keyword", + "ignore_above": 1024 + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "type": "keyword", + "ignore_above": 1024 + }, + "x509": { + "properties": { + "alternative_names": { + "type": "keyword", + "ignore_above": 1024 + }, + "issuer": { + "properties": { + "common_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country": { + "type": "keyword", + "ignore_above": 1024 + }, + "distinguished_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "locality": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "organizational_unit": { + "type": "keyword", + "ignore_above": 1024 + }, + "state_or_province": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "type": "keyword", + "ignore_above": 1024 + }, + "public_key_curve": { + "type": "keyword", + "ignore_above": 1024 + }, + "public_key_exponent": { + "type": "long", + "index": false, + "doc_values": false + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "signature_algorithm": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject": { + "properties": { + "common_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country": { + "type": "keyword", + "ignore_above": 1024 + }, + "distinguished_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "locality": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "organizational_unit": { + "type": "keyword", + "ignore_above": 1024 + }, + "state_or_province": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "version_number": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + }, + "version_protocol": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "trace": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "transaction": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "url": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "extension": { + "type": "keyword", + "ignore_above": 1024 + }, + "fragment": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "original": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "password": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "port": { + "type": "long" + }, + "query": { + "type": "keyword", + "ignore_above": 1024 + }, + "registered_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "scheme": { + "type": "keyword", + "ignore_above": 1024 + }, + "subdomain": { + "type": "keyword", + "ignore_above": 1024 + }, + "top_level_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "username": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "user": { + "properties": { + "changes": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false } } }, - "first_seen": { - "type": "date" - }, - "geo": { + "group": { "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" + "domain": { + "type": "keyword", + "ignore_above": 1024 }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" + "id": { + "type": "keyword", + "ignore_above": 1024 }, "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 } } }, - "ip": { - "type": "ip" + "hash": { + "type": "keyword", + "ignore_above": 1024 }, - "last_seen": { - "type": "date" + "id": { + "type": "keyword", + "ignore_above": 1024 }, - "marking": { - "properties": { - "tlp": { - "ignore_above": 1024, - "type": "keyword" + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false } } }, - "matched": { + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "effective": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { "properties": { - "atomic": { - "ignore_above": 1024, - "type": "keyword" + "domain": { + "type": "keyword", + "ignore_above": 1024 }, - "field": { - "ignore_above": 1024, - "type": "keyword" + "id": { + "type": "keyword", + "ignore_above": 1024 }, - "type": { - "ignore_above": 1024, - "type": "keyword" + "name": { + "type": "keyword", + "ignore_above": 1024 } } }, - "module": { - "ignore_above": 1024, - "type": "keyword" + "hash": { + "type": "keyword", + "ignore_above": 1024 }, - "port": { - "type": "long" + "id": { + "type": "keyword", + "ignore_above": 1024 }, - "provider": { + "name": { + "type": "keyword", "ignore_above": 1024, - "type": "keyword" + "fields": { + "text": { + "type": "text", + "norms": false + } + } }, - "scanner_stats": { - "type": "long" + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 }, - "sightings": { - "type": "long" + "id": { + "type": "keyword", + "ignore_above": 1024 }, - "type": { - "ignore_above": 1024, - "type": "keyword" + "name": { + "type": "keyword", + "ignore_above": 1024 } - }, - "type": "nested" + } }, - "tactic": { + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "roles": { + "type": "keyword", + "ignore_above": 1024 + }, + "target": { "properties": { - "id": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", "ignore_above": 1024, - "type": "keyword" + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 }, "name": { + "type": "keyword", "ignore_above": 1024, - "type": "keyword" + "fields": { + "text": { + "type": "text", + "norms": false + } + } }, - "reference": { - "ignore_above": 1024, - "type": "keyword" + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 } } }, - "technique": { + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "original": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "os": { "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" + "family": { + "type": "keyword", + "ignore_above": 1024 }, - "name": { + "full": { + "type": "keyword", + "ignore_above": 1024, "fields": { "text": { - "norms": false, - "type": "text" + "type": "text", + "norms": false } - }, - "ignore_above": 1024, - "type": "keyword" + } }, - "reference": { - "ignore_above": 1024, - "type": "keyword" + "kernel": { + "type": "keyword", + "ignore_above": 1024 }, - "subtechnique": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "reference": { - "ignore_above": 1024, - "type": "keyword" + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false } } + }, + "platform": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "vlan": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "vulnerability": { + "properties": { + "category": { + "type": "keyword", + "ignore_above": 1024 + }, + "classification": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false } } + }, + "enumeration": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + }, + "report_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "scanner": { + "properties": { + "vendor": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "severity": { + "type": "keyword", + "ignore_above": 1024 } } } From f0e5e2db052e06d918244367d027432a6aa9aa45 Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Tue, 15 Feb 2022 20:23:11 +0100 Subject: [PATCH 24/39] use locator in osquery plugin (#125698) --- .../osquery/public/common/hooks/use_discover_link.tsx | 8 ++++---- .../osquery/public/packs/pack_queries_status_table.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/osquery/public/common/hooks/use_discover_link.tsx b/x-pack/plugins/osquery/public/common/hooks/use_discover_link.tsx index dd091d80ce62e..d930d867c7a8b 100644 --- a/x-pack/plugins/osquery/public/common/hooks/use_discover_link.tsx +++ b/x-pack/plugins/osquery/public/common/hooks/use_discover_link.tsx @@ -18,14 +18,14 @@ export const useDiscoverLink = ({ filters }: UseDiscoverLink) => { const { application: { navigateToUrl }, } = useKibana().services; - const urlGenerator = useKibana().services.discover?.urlGenerator; + const locator = useKibana().services.discover?.locator; const [discoverUrl, setDiscoverUrl] = useState(''); useEffect(() => { const getDiscoverUrl = async () => { - if (!urlGenerator?.createUrl) return; + if (!locator) return; - const newUrl = await urlGenerator.createUrl({ + const newUrl = await locator.getUrl({ indexPatternId: 'logs-*', filters: filters.map((filter) => ({ meta: { @@ -44,7 +44,7 @@ export const useDiscoverLink = ({ filters }: UseDiscoverLink) => { setDiscoverUrl(newUrl); }; getDiscoverUrl(); - }, [filters, urlGenerator]); + }, [filters, locator]); const onClick = useCallback( (event: React.MouseEvent) => { diff --git a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx index c982cdd5604d1..836350d12d43e 100644 --- a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx +++ b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx @@ -264,12 +264,12 @@ const ViewResultsInDiscoverActionComponent: React.FC { - const urlGenerator = useKibana().services.discover?.urlGenerator; + const locator = useKibana().services.discover?.locator; const [discoverUrl, setDiscoverUrl] = useState(''); useEffect(() => { const getDiscoverUrl = async () => { - if (!urlGenerator?.createUrl) return; + if (!locator) return; const agentIdsQuery = agentIds?.length ? { @@ -280,7 +280,7 @@ const ViewResultsInDiscoverActionComponent: React.FC Date: Tue, 15 Feb 2022 13:35:33 -0600 Subject: [PATCH 25/39] [cft] Generate update plan based on current configuration (#125317) When a Cloud deployment is updated, we're reusing the original creation template. In cases where settings are manually overridden. these changes will be lost when a deployment is updated. Instead of using the base template, this queries the cloud API for an update payload as a base. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../scripts/steps/cloud/build_and_deploy.sh | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.buildkite/scripts/steps/cloud/build_and_deploy.sh b/.buildkite/scripts/steps/cloud/build_and_deploy.sh index 9ea6c4f445328..7227d3a8a5e57 100755 --- a/.buildkite/scripts/steps/cloud/build_and_deploy.sh +++ b/.buildkite/scripts/steps/cloud/build_and_deploy.sh @@ -35,16 +35,16 @@ node scripts/build \ CLOUD_IMAGE=$(docker images --format "{{.Repository}}:{{.Tag}}" docker.elastic.co/kibana-ci/kibana-cloud) CLOUD_DEPLOYMENT_NAME="kibana-pr-$BUILDKITE_PULL_REQUEST" -jq ' - .resources.kibana[0].plan.kibana.docker_image = "'$CLOUD_IMAGE'" | - .name = "'$CLOUD_DEPLOYMENT_NAME'" | - .resources.kibana[0].plan.kibana.version = "'$VERSION'" | - .resources.elasticsearch[0].plan.elasticsearch.version = "'$VERSION'" - ' .buildkite/scripts/steps/cloud/deploy.json > /tmp/deploy.json - CLOUD_DEPLOYMENT_ID=$(ecctl deployment list --output json | jq -r '.deployments[] | select(.name == "'$CLOUD_DEPLOYMENT_NAME'") | .id') JSON_FILE=$(mktemp --suffix ".json") if [ -z "${CLOUD_DEPLOYMENT_ID}" ]; then + jq ' + .resources.kibana[0].plan.kibana.docker_image = "'$CLOUD_IMAGE'" | + .name = "'$CLOUD_DEPLOYMENT_NAME'" | + .resources.kibana[0].plan.kibana.version = "'$VERSION'" | + .resources.elasticsearch[0].plan.elasticsearch.version = "'$VERSION'" + ' .buildkite/scripts/steps/cloud/deploy.json > /tmp/deploy.json + ecctl deployment create --track --output json --file /tmp/deploy.json &> "$JSON_FILE" CLOUD_DEPLOYMENT_USERNAME=$(jq --slurp '.[]|select(.resources).resources[] | select(.credentials).credentials.username' "$JSON_FILE") CLOUD_DEPLOYMENT_PASSWORD=$(jq --slurp '.[]|select(.resources).resources[] | select(.credentials).credentials.password' "$JSON_FILE") @@ -59,6 +59,11 @@ if [ -z "${CLOUD_DEPLOYMENT_ID}" ]; then retry 5 5 vault write "secret/kibana-issues/dev/cloud-deploy/$CLOUD_DEPLOYMENT_NAME" username="$CLOUD_DEPLOYMENT_USERNAME" password="$CLOUD_DEPLOYMENT_PASSWORD" else + ecctl deployment show "$CLOUD_DEPLOYMENT_ID" --generate-update-payload | jq ' + .resources.kibana[0].plan.kibana.docker_image = "'$CLOUD_IMAGE'" | + .resources.kibana[0].plan.kibana.version = "'$VERSION'" | + .resources.elasticsearch[0].plan.elasticsearch.version = "'$VERSION'" + ' > /tmp/deploy.json ecctl deployment update "$CLOUD_DEPLOYMENT_ID" --track --output json --file /tmp/deploy.json &> "$JSON_FILE" fi From 16f3eb352cccd90649fbb6f0a89432193fb66a34 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 15 Feb 2022 13:46:42 -0600 Subject: [PATCH 26/39] [ci] Verify docker contexts (#122897) * [ci] Verify docker contexts * bootstrap * debug * mkdir target * change subdomain if snapshot * move to separate pipeline Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .buildkite/pipelines/docker_context.yml | 11 +++++++++++ .buildkite/scripts/steps/docker_context/build.sh | 16 ++++++++++++++++ .../tasks/os_packages/docker_generator/run.ts | 2 ++ .../docker_generator/template_context.ts | 1 + .../docker_generator/templates/base/Dockerfile | 2 +- 5 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 .buildkite/pipelines/docker_context.yml create mode 100644 .buildkite/scripts/steps/docker_context/build.sh diff --git a/.buildkite/pipelines/docker_context.yml b/.buildkite/pipelines/docker_context.yml new file mode 100644 index 0000000000000..f85b895e4780b --- /dev/null +++ b/.buildkite/pipelines/docker_context.yml @@ -0,0 +1,11 @@ + steps: + - command: .buildkite/scripts/steps/docker_context/build.sh + label: 'Docker Build Context' + agents: + queue: n2-4 + timeout_in_minutes: 30 + key: build-docker-context + retry: + automatic: + - exit_status: '*' + limit: 1 \ No newline at end of file diff --git a/.buildkite/scripts/steps/docker_context/build.sh b/.buildkite/scripts/steps/docker_context/build.sh new file mode 100644 index 0000000000000..42152d005ffa9 --- /dev/null +++ b/.buildkite/scripts/steps/docker_context/build.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -euo pipefail + +.buildkite/scripts/bootstrap.sh + +echo "--- Create Kibana Docker contexts" +mkdir -p target +node scripts/build --skip-initialize --skip-generic-folders --skip-platform-folders --skip-archives + +echo "--- Build default context" +DOCKER_BUILD_FOLDER=$(mktemp -d) + +tar -xf target/kibana-[0-9]*-docker-build-context.tar.gz -C "$DOCKER_BUILD_FOLDER" +cd $DOCKER_BUILD_FOLDER +docker build . diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 657efc8d7bd99..332605e926537 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -76,6 +76,7 @@ export async function runDockerGenerator( const dockerPush = config.getDockerPush(); const dockerTagQualifier = config.getDockerTagQualfiier(); + const publicArtifactSubdomain = config.isRelease ? 'artifacts' : 'snapshots-no-kpi'; const scope: TemplateContext = { artifactPrefix, @@ -100,6 +101,7 @@ export async function runDockerGenerator( ironbank: flags.ironbank, architecture: flags.architecture, revision: config.getBuildSha(), + publicArtifactSubdomain, }; type HostArchitectureToDocker = Record; diff --git a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts index a715bfaa5d50d..524cfcef18284 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts @@ -22,6 +22,7 @@ export interface TemplateContext { baseOSImage: string; dockerBuildDate: string; usePublicArtifact?: boolean; + publicArtifactSubdomain: string; ubi?: boolean; ubuntu?: boolean; cloud?: boolean; diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile index 54af1c41b2da9..95f6a56ef68cb 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile @@ -22,7 +22,7 @@ RUN {{packageManager}} update && DEBIAN_FRONTEND=noninteractive {{packageManager RUN cd /tmp && \ curl --retry 8 -s -L \ --output kibana.tar.gz \ - https://artifacts.elastic.co/downloads/kibana/{{artifactPrefix}}-$(arch).tar.gz && \ + https://{{publicArtifactSubdomain}}.elastic.co/downloads/kibana/{{artifactPrefix}}-$(arch).tar.gz && \ cd - {{/usePublicArtifact}} From 7c850dd81fc3062ba6ba0242b5498f4482eff540 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 15 Feb 2022 12:57:50 -0700 Subject: [PATCH 27/39] [Security Solutions] Updates usage collector telemetry to use PIT (Point in Time) and restructuring of folders (#124912) ## Summary Changes the usage collector telemetry within security solutions to use PIT (Point in Time) and a few other bug fixes and restructuring. * The main goal is to change the full queries for up to 10k items to be instead using 1k batched items at a time and PIT (Point in Time). See [this ticket](https://github.com/elastic/kibana/issues/93770) for more information and [here](https://github.com/elastic/kibana/pull/99031) for an example where they changed there code to use 1k batched items. I use PIT with SO object API, searches, and then composite aggregations which all support the PIT. The PIT timeouts are all set to 5 minutes and all the maximums of 10k to not increase memory more is still there. However, we should be able to increase the 10k limit at this point if we wanted to for usage collector to count beyond the 10k. The initial 10k was an elastic limitation that PIT now avoids. * This also fixes a bug where the aggregations were only returning the top 10 items instead of the full 10k. That is changed to use [composite aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-composite-aggregation.html). * This restructuring the folder structure to try and do [reductionism](https://en.wikipedia.org/wiki/Reductionism#In_computer_science) best we can. I could not do reductionism with the schema as the tooling does not allow it. But the rest is self-repeating in the way hopefully developers expect it to be. And also make it easier for developers to add new telemetry usage collector counters in the same fashion. * This exchanges the hand spun TypeScript types in favor of using the `caseComments` and the `Sanitized Alerts` and the `ML job types` using Partial and other TypeScript tricks. * This removes the [Cyclomatic Complexity](https://en.wikipedia.org/wiki/Cyclomatic_complexity) warnings coming from the linters by breaking down the functions into smaller units. * This removes the "as casts" in all but 1 area which can lead to subtle TypeScript problems. * This pushes down the logger and uses the logger to report errors and some debug information ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../security_solution/server/plugin.ts | 2 +- .../server/usage/collector.ts | 40 +- .../server/usage/constants.ts | 26 + .../usage/detections/detection_ml_helpers.ts | 175 ------ .../detections/detection_rule_helpers.ts | 503 ------------------ .../usage/detections/get_initial_usage.ts | 25 + ...detections.test.ts => get_metrics.test.ts} | 139 +++-- .../server/usage/detections/get_metrics.ts | 47 ++ .../server/usage/detections/index.ts | 41 -- .../detections/ml_jobs/get_initial_usage.ts | 22 + .../get_metrics.mocks.ts} | 404 ++++++-------- .../usage/detections/ml_jobs/get_metrics.ts | 100 ++++ .../transform_utils/get_job_correlations.ts | 71 +++ .../server/usage/detections/ml_jobs/types.ts | 56 ++ .../update_usage.test.ts} | 18 +- .../usage/detections/ml_jobs/update_usage.ts | 47 ++ .../detections/rules/get_initial_usage.ts | 84 +++ .../detections/rules/get_metrics.mocks.ts | 99 ++++ .../usage/detections/rules/get_metrics.ts | 122 +++++ .../get_alert_id_to_count_map.ts | 14 + .../get_rule_id_to_cases_map.ts | 30 ++ .../get_rule_id_to_enabled_map.ts | 32 ++ .../get_rule_object_correlations.ts | 62 +++ .../server/usage/detections/rules/types.ts | 47 ++ .../update_usage.test.ts} | 35 +- .../usage/detections/rules/update_usage.ts | 85 +++ .../get_notifications_enabled_disabled.ts | 26 + .../rules/usage_utils/update_query_usage.ts | 48 ++ .../rules/usage_utils/update_total_usage.ts | 51 ++ .../server/usage/detections/types.ts | 163 +----- .../get_internal_saved_objects_client.ts | 25 + .../server/usage/queries/get_alerts.ts | 115 ++++ .../server/usage/queries/get_case_comments.ts | 62 +++ .../usage/queries/get_detection_rules.ts | 82 +++ .../usage/queries/legacy_get_rule_actions.ts | 73 +++ .../queries/utils/fetch_hits_with_pit.ts | 79 +++ .../usage/queries/utils/is_elastic_rule.ts | 11 + .../security_solution/server/usage/types.ts | 44 +- .../telemetry/usage_collector/all_types.ts | 4 +- .../usage_collector/detection_rules.ts | 8 +- 40 files changed, 1896 insertions(+), 1221 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/usage/constants.ts delete mode 100644 x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.ts delete mode 100644 x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/get_initial_usage.ts rename x-pack/plugins/security_solution/server/usage/detections/{detections.test.ts => get_metrics.test.ts} (66%) create mode 100644 x-pack/plugins/security_solution/server/usage/detections/get_metrics.ts delete mode 100644 x-pack/plugins/security_solution/server/usage/detections/index.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_initial_usage.ts rename x-pack/plugins/security_solution/server/usage/detections/{detections.mocks.ts => ml_jobs/get_metrics.mocks.ts} (65%) create mode 100644 x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/ml_jobs/transform_utils/get_job_correlations.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/ml_jobs/types.ts rename x-pack/plugins/security_solution/server/usage/detections/{detection_ml_helpers.test.ts => ml_jobs/update_usage.test.ts} (70%) create mode 100644 x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/get_initial_usage.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.mocks.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_alert_id_to_count_map.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_cases_map.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_enabled_map.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_object_correlations.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/types.ts rename x-pack/plugins/security_solution/server/usage/detections/{detection_rule_helpers.test.ts => rules/update_usage.test.ts} (92%) create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/get_notifications_enabled_disabled.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_query_usage.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_total_usage.ts create mode 100644 x-pack/plugins/security_solution/server/usage/get_internal_saved_objects_client.ts create mode 100644 x-pack/plugins/security_solution/server/usage/queries/get_alerts.ts create mode 100644 x-pack/plugins/security_solution/server/usage/queries/get_case_comments.ts create mode 100644 x-pack/plugins/security_solution/server/usage/queries/get_detection_rules.ts create mode 100644 x-pack/plugins/security_solution/server/usage/queries/legacy_get_rule_actions.ts create mode 100644 x-pack/plugins/security_solution/server/usage/queries/utils/fetch_hits_with_pit.ts create mode 100644 x-pack/plugins/security_solution/server/usage/queries/utils/is_elastic_rule.ts diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index b34a2a4f3a7d6..511679ef71a79 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -168,10 +168,10 @@ export class Plugin implements ISecuritySolutionPlugin { initUsageCollectors({ core, - kibanaIndex: core.savedObjects.getKibanaIndex(), signalsIndex: DEFAULT_ALERTS_INDEX, ml: plugins.ml, usageCollection: plugins.usageCollection, + logger, }); this.telemetryUsageCounter = plugins.usageCollection?.createUsageCounter(APP_ID); diff --git a/x-pack/plugins/security_solution/server/usage/collector.ts b/x-pack/plugins/security_solution/server/usage/collector.ts index 4530dac725c7b..dc98b68f9f186 100644 --- a/x-pack/plugins/security_solution/server/usage/collector.ts +++ b/x-pack/plugins/security_solution/server/usage/collector.ts @@ -5,38 +5,26 @@ * 2.0. */ -import { CoreSetup, SavedObjectsClientContract } from '../../../../../src/core/server'; -import { CollectorFetchContext } from '../../../../../src/plugins/usage_collection/server'; -import { CollectorDependencies } from './types'; -import { fetchDetectionsMetrics } from './detections'; -import { SAVED_OBJECT_TYPES } from '../../../cases/common/constants'; -// eslint-disable-next-line no-restricted-imports -import { legacyRuleActionsSavedObjectType } from '../lib/detection_engine/rule_actions/legacy_saved_object_mappings'; +import type { CollectorFetchContext } from '../../../../../src/plugins/usage_collection/server'; +import type { CollectorDependencies } from './types'; +import { getDetectionsMetrics } from './detections/get_metrics'; +import { getInternalSavedObjectsClient } from './get_internal_saved_objects_client'; export type RegisterCollector = (deps: CollectorDependencies) => void; + export interface UsageData { detectionMetrics: {}; } -export async function getInternalSavedObjectsClient(core: CoreSetup) { - return core.getStartServices().then(async ([coreStart]) => { - // note: we include the "cases" and "alert" hidden types here otherwise we would not be able to query them. If at some point cases and alert is not considered a hidden type this can be removed - return coreStart.savedObjects.createInternalRepository([ - 'alert', - legacyRuleActionsSavedObjectType, - ...SAVED_OBJECT_TYPES, - ]); - }); -} - export const registerCollector: RegisterCollector = ({ core, - kibanaIndex, signalsIndex, ml, usageCollection, + logger, }) => { if (!usageCollection) { + logger.debug('Usage collection is undefined, therefore returning early without registering it'); return; } @@ -525,12 +513,16 @@ export const registerCollector: RegisterCollector = ({ }, isReady: () => true, fetch: async ({ esClient }: CollectorFetchContext): Promise => { - const internalSavedObjectsClient = await getInternalSavedObjectsClient(core); - const soClient = internalSavedObjectsClient as unknown as SavedObjectsClientContract; - + const savedObjectsClient = await getInternalSavedObjectsClient(core); + const detectionMetrics = await getDetectionsMetrics({ + signalsIndex, + esClient, + savedObjectsClient, + logger, + mlClient: ml, + }); return { - detectionMetrics: - (await fetchDetectionsMetrics(kibanaIndex, signalsIndex, esClient, soClient, ml)) || {}, + detectionMetrics: detectionMetrics || {}, }; }, }); diff --git a/x-pack/plugins/security_solution/server/usage/constants.ts b/x-pack/plugins/security_solution/server/usage/constants.ts new file mode 100644 index 0000000000000..d3d526768fcd5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/constants.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * We limit the max results window to prevent in-memory from blowing up when we do correlation. + * This is limiting us to 10,000 cases and 10,000 elastic detection rules to do telemetry and correlation + * and the choice was based on the initial "index.max_result_window" before this turned into a PIT (Point In Time) + * implementation. + * + * This number could be changed, and the implementation details of how we correlate could change as well (maybe) + * to avoid pulling 10,000 worth of cases and elastic rules into memory. + * + * However, for now, we are keeping this maximum as the original and the in-memory implementation + */ +export const MAX_RESULTS_WINDOW = 10_000; + +/** + * We choose our max per page based on 1k as that + * appears to be what others are choosing here in the other sections of telemetry: + * https://github.com/elastic/kibana/pull/99031 + */ +export const MAX_PER_PAGE = 1_000; diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.ts deleted file mode 100644 index 1aadcfdc5478a..0000000000000 --- a/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.ts +++ /dev/null @@ -1,175 +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 { KibanaRequest, SavedObjectsClientContract } from '../../../../../../src/core/server'; -import { MlDatafeedStats, MlJob, MlPluginSetup } from '../../../../ml/server'; -import { isJobStarted } from '../../../common/machine_learning/helpers'; -import { isSecurityJob } from '../../../common/machine_learning/is_security_job'; -import { DetectionsMetric, MlJobMetric, MlJobsUsage, MlJobUsage } from './types'; - -/** - * Default ml job usage count - */ -export const initialMlJobsUsage: MlJobsUsage = { - custom: { - enabled: 0, - disabled: 0, - }, - elastic: { - enabled: 0, - disabled: 0, - }, -}; - -export const updateMlJobsUsage = (jobMetric: DetectionsMetric, usage: MlJobsUsage): MlJobsUsage => { - const { isEnabled, isElastic } = jobMetric; - if (isEnabled && isElastic) { - return { - ...usage, - elastic: { - ...usage.elastic, - enabled: usage.elastic.enabled + 1, - }, - }; - } else if (!isEnabled && isElastic) { - return { - ...usage, - elastic: { - ...usage.elastic, - disabled: usage.elastic.disabled + 1, - }, - }; - } else if (isEnabled && !isElastic) { - return { - ...usage, - custom: { - ...usage.custom, - enabled: usage.custom.enabled + 1, - }, - }; - } else if (!isEnabled && !isElastic) { - return { - ...usage, - custom: { - ...usage.custom, - disabled: usage.custom.disabled + 1, - }, - }; - } else { - return usage; - } -}; - -export const getMlJobMetrics = async ( - ml: MlPluginSetup | undefined, - savedObjectClient: SavedObjectsClientContract -): Promise => { - let jobsUsage: MlJobsUsage = initialMlJobsUsage; - - if (ml) { - try { - const fakeRequest = { headers: {} } as KibanaRequest; - - const modules = await ml.modulesProvider(fakeRequest, savedObjectClient).listModules(); - const moduleJobs = modules.flatMap((module) => module.jobs); - const jobs = await ml.jobServiceProvider(fakeRequest, savedObjectClient).jobsSummary(); - - jobsUsage = jobs.filter(isSecurityJob).reduce((usage, job) => { - const isElastic = moduleJobs.some((moduleJob) => moduleJob.id === job.id); - const isEnabled = isJobStarted(job.jobState, job.datafeedState); - - return updateMlJobsUsage({ isElastic, isEnabled }, usage); - }, initialMlJobsUsage); - - const jobsType = 'security'; - const securityJobStats = await ml - .anomalyDetectorsProvider(fakeRequest, savedObjectClient) - .jobStats(jobsType); - - const jobDetails = await ml - .anomalyDetectorsProvider(fakeRequest, savedObjectClient) - .jobs(jobsType); - - const jobDetailsCache = new Map(); - jobDetails.jobs.forEach((detail) => jobDetailsCache.set(detail.job_id, detail)); - - const datafeedStats = await ml - .anomalyDetectorsProvider(fakeRequest, savedObjectClient) - .datafeedStats(); - - const datafeedStatsCache = new Map(); - datafeedStats.datafeeds.forEach((datafeedStat) => - datafeedStatsCache.set(`${datafeedStat.datafeed_id}`, datafeedStat) - ); - - const jobMetrics: MlJobMetric[] = securityJobStats.jobs.map((stat) => { - const jobId = stat.job_id; - const jobDetail = jobDetailsCache.get(stat.job_id); - const datafeed = datafeedStatsCache.get(`datafeed-${jobId}`); - - return { - job_id: jobId, - open_time: stat.open_time, - create_time: jobDetail?.create_time, - finished_time: jobDetail?.finished_time, - state: stat.state, - data_counts: { - bucket_count: stat.data_counts.bucket_count, - empty_bucket_count: stat.data_counts.empty_bucket_count, - input_bytes: stat.data_counts.input_bytes, - input_record_count: stat.data_counts.input_record_count, - last_data_time: stat.data_counts.last_data_time, - processed_record_count: stat.data_counts.processed_record_count, - }, - model_size_stats: { - bucket_allocation_failures_count: - stat.model_size_stats.bucket_allocation_failures_count, - memory_status: stat.model_size_stats.memory_status, - model_bytes: stat.model_size_stats.model_bytes, - model_bytes_exceeded: stat.model_size_stats.model_bytes_exceeded, - model_bytes_memory_limit: stat.model_size_stats.model_bytes_memory_limit, - peak_model_bytes: stat.model_size_stats.peak_model_bytes, - }, - timing_stats: { - average_bucket_processing_time_ms: stat.timing_stats.average_bucket_processing_time_ms, - bucket_count: stat.timing_stats.bucket_count, - exponential_average_bucket_processing_time_ms: - stat.timing_stats.exponential_average_bucket_processing_time_ms, - exponential_average_bucket_processing_time_per_hour_ms: - stat.timing_stats.exponential_average_bucket_processing_time_per_hour_ms, - maximum_bucket_processing_time_ms: stat.timing_stats.maximum_bucket_processing_time_ms, - minimum_bucket_processing_time_ms: stat.timing_stats.minimum_bucket_processing_time_ms, - total_bucket_processing_time_ms: stat.timing_stats.total_bucket_processing_time_ms, - }, - datafeed: { - datafeed_id: datafeed?.datafeed_id, - state: datafeed?.state, - timing_stats: { - bucket_count: datafeed?.timing_stats.bucket_count, - exponential_average_search_time_per_hour_ms: - datafeed?.timing_stats.exponential_average_search_time_per_hour_ms, - search_count: datafeed?.timing_stats.search_count, - total_search_time_ms: datafeed?.timing_stats.total_search_time_ms, - }, - }, - } as MlJobMetric; - }); - - return { - ml_job_usage: jobsUsage, - ml_job_metrics: jobMetrics, - }; - } catch (e) { - // ignore failure, usage will be zeroed - } - } - - return { - ml_job_usage: initialMlJobsUsage, - ml_job_metrics: [], - }; -}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts deleted file mode 100644 index 39c108931e2d7..0000000000000 --- a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts +++ /dev/null @@ -1,503 +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 { - SIGNALS_ID, - EQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, -} from '@kbn/securitysolution-rules'; -import { ALERT_RULE_UUID } from '@kbn/rule-data-utils'; -import { CASE_COMMENT_SAVED_OBJECT } from '../../../../cases/common/constants'; - -import { ElasticsearchClient, SavedObjectsClientContract } from '../../../../../../src/core/server'; -import { isElasticRule } from './index'; -import type { - AlertsAggregationResponse, - CasesSavedObject, - DetectionRulesTypeUsage, - DetectionRuleMetric, - DetectionRuleAdoption, - RuleSearchParams, - RuleSearchResult, - DetectionMetrics, -} from './types'; -// eslint-disable-next-line no-restricted-imports -import { legacyRuleActionsSavedObjectType } from '../../lib/detection_engine/rule_actions/legacy_saved_object_mappings'; -// eslint-disable-next-line no-restricted-imports -import { LegacyIRuleActionsAttributesSavedObjectAttributes } from '../../lib/detection_engine/rule_actions/legacy_types'; - -/** - * Initial detection metrics initialized. - */ -export const getInitialDetectionMetrics = (): DetectionMetrics => ({ - ml_jobs: { - ml_job_usage: { - custom: { - enabled: 0, - disabled: 0, - }, - elastic: { - enabled: 0, - disabled: 0, - }, - }, - ml_job_metrics: [], - }, - detection_rules: { - detection_rule_detail: [], - detection_rule_usage: initialDetectionRulesUsage, - }, -}); - -/** - * Default detection rule usage count, split by type + elastic/custom - */ -export const initialDetectionRulesUsage: DetectionRulesTypeUsage = { - query: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, - threshold: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, - eql: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, - machine_learning: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, - threat_match: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, - elastic_total: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, - custom_total: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, -}; - -/* eslint-disable complexity */ -export const updateDetectionRuleUsage = ( - detectionRuleMetric: DetectionRuleMetric, - usage: DetectionRulesTypeUsage -): DetectionRulesTypeUsage => { - let updatedUsage = usage; - - const legacyNotificationEnabled = - detectionRuleMetric.has_legacy_notification && detectionRuleMetric.enabled; - - const legacyNotificationDisabled = - detectionRuleMetric.has_legacy_notification && !detectionRuleMetric.enabled; - - const notificationEnabled = detectionRuleMetric.has_notification && detectionRuleMetric.enabled; - - const notificationDisabled = detectionRuleMetric.has_notification && !detectionRuleMetric.enabled; - - if (detectionRuleMetric.rule_type === 'query') { - updatedUsage = { - ...usage, - query: { - ...usage.query, - enabled: detectionRuleMetric.enabled ? usage.query.enabled + 1 : usage.query.enabled, - disabled: !detectionRuleMetric.enabled ? usage.query.disabled + 1 : usage.query.disabled, - alerts: usage.query.alerts + detectionRuleMetric.alert_count_daily, - cases: usage.query.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? usage.query.legacy_notifications_enabled + 1 - : usage.query.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? usage.query.legacy_notifications_disabled + 1 - : usage.query.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? usage.query.notifications_enabled + 1 - : usage.query.notifications_enabled, - notifications_disabled: notificationDisabled - ? usage.query.notifications_disabled + 1 - : usage.query.notifications_disabled, - }, - }; - } else if (detectionRuleMetric.rule_type === 'threshold') { - updatedUsage = { - ...usage, - threshold: { - ...usage.threshold, - enabled: detectionRuleMetric.enabled - ? usage.threshold.enabled + 1 - : usage.threshold.enabled, - disabled: !detectionRuleMetric.enabled - ? usage.threshold.disabled + 1 - : usage.threshold.disabled, - alerts: usage.threshold.alerts + detectionRuleMetric.alert_count_daily, - cases: usage.threshold.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? usage.threshold.legacy_notifications_enabled + 1 - : usage.threshold.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? usage.threshold.legacy_notifications_disabled + 1 - : usage.threshold.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? usage.threshold.notifications_enabled + 1 - : usage.threshold.notifications_enabled, - notifications_disabled: notificationDisabled - ? usage.threshold.notifications_disabled + 1 - : usage.threshold.notifications_disabled, - }, - }; - } else if (detectionRuleMetric.rule_type === 'eql') { - updatedUsage = { - ...usage, - eql: { - ...usage.eql, - enabled: detectionRuleMetric.enabled ? usage.eql.enabled + 1 : usage.eql.enabled, - disabled: !detectionRuleMetric.enabled ? usage.eql.disabled + 1 : usage.eql.disabled, - alerts: usage.eql.alerts + detectionRuleMetric.alert_count_daily, - cases: usage.eql.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? usage.eql.legacy_notifications_enabled + 1 - : usage.eql.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? usage.eql.legacy_notifications_disabled + 1 - : usage.eql.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? usage.eql.notifications_enabled + 1 - : usage.eql.notifications_enabled, - notifications_disabled: notificationDisabled - ? usage.eql.notifications_disabled + 1 - : usage.eql.notifications_disabled, - }, - }; - } else if (detectionRuleMetric.rule_type === 'machine_learning') { - updatedUsage = { - ...usage, - machine_learning: { - ...usage.machine_learning, - enabled: detectionRuleMetric.enabled - ? usage.machine_learning.enabled + 1 - : usage.machine_learning.enabled, - disabled: !detectionRuleMetric.enabled - ? usage.machine_learning.disabled + 1 - : usage.machine_learning.disabled, - alerts: usage.machine_learning.alerts + detectionRuleMetric.alert_count_daily, - cases: usage.machine_learning.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? usage.machine_learning.legacy_notifications_enabled + 1 - : usage.machine_learning.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? usage.machine_learning.legacy_notifications_disabled + 1 - : usage.machine_learning.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? usage.machine_learning.notifications_enabled + 1 - : usage.machine_learning.notifications_enabled, - notifications_disabled: notificationDisabled - ? usage.machine_learning.notifications_disabled + 1 - : usage.machine_learning.notifications_disabled, - }, - }; - } else if (detectionRuleMetric.rule_type === 'threat_match') { - updatedUsage = { - ...usage, - threat_match: { - ...usage.threat_match, - enabled: detectionRuleMetric.enabled - ? usage.threat_match.enabled + 1 - : usage.threat_match.enabled, - disabled: !detectionRuleMetric.enabled - ? usage.threat_match.disabled + 1 - : usage.threat_match.disabled, - alerts: usage.threat_match.alerts + detectionRuleMetric.alert_count_daily, - cases: usage.threat_match.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? usage.threat_match.legacy_notifications_enabled + 1 - : usage.threat_match.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? usage.threat_match.legacy_notifications_disabled + 1 - : usage.threat_match.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? usage.threat_match.notifications_enabled + 1 - : usage.threat_match.notifications_enabled, - notifications_disabled: notificationDisabled - ? usage.threat_match.notifications_disabled + 1 - : usage.threat_match.notifications_disabled, - }, - }; - } - - if (detectionRuleMetric.elastic_rule) { - updatedUsage = { - ...updatedUsage, - elastic_total: { - ...updatedUsage.elastic_total, - enabled: detectionRuleMetric.enabled - ? updatedUsage.elastic_total.enabled + 1 - : updatedUsage.elastic_total.enabled, - disabled: !detectionRuleMetric.enabled - ? updatedUsage.elastic_total.disabled + 1 - : updatedUsage.elastic_total.disabled, - alerts: updatedUsage.elastic_total.alerts + detectionRuleMetric.alert_count_daily, - cases: updatedUsage.elastic_total.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? updatedUsage.elastic_total.legacy_notifications_enabled + 1 - : updatedUsage.elastic_total.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? updatedUsage.elastic_total.legacy_notifications_disabled + 1 - : updatedUsage.elastic_total.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? updatedUsage.elastic_total.notifications_enabled + 1 - : updatedUsage.elastic_total.notifications_enabled, - notifications_disabled: notificationDisabled - ? updatedUsage.elastic_total.notifications_disabled + 1 - : updatedUsage.elastic_total.notifications_disabled, - }, - }; - } else { - updatedUsage = { - ...updatedUsage, - custom_total: { - ...updatedUsage.custom_total, - enabled: detectionRuleMetric.enabled - ? updatedUsage.custom_total.enabled + 1 - : updatedUsage.custom_total.enabled, - disabled: !detectionRuleMetric.enabled - ? updatedUsage.custom_total.disabled + 1 - : updatedUsage.custom_total.disabled, - alerts: updatedUsage.custom_total.alerts + detectionRuleMetric.alert_count_daily, - cases: updatedUsage.custom_total.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? updatedUsage.custom_total.legacy_notifications_enabled + 1 - : updatedUsage.custom_total.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? updatedUsage.custom_total.legacy_notifications_disabled + 1 - : updatedUsage.custom_total.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? updatedUsage.custom_total.notifications_enabled + 1 - : updatedUsage.custom_total.notifications_enabled, - notifications_disabled: notificationDisabled - ? updatedUsage.custom_total.notifications_disabled + 1 - : updatedUsage.custom_total.notifications_disabled, - }, - }; - } - - return updatedUsage; -}; - -const MAX_RESULTS_WINDOW = 10_000; // elasticsearch index.max_result_window default value - -export const getDetectionRuleMetrics = async ( - kibanaIndex: string, - signalsIndex: string, - esClient: ElasticsearchClient, - savedObjectClient: SavedObjectsClientContract -): Promise => { - let rulesUsage: DetectionRulesTypeUsage = initialDetectionRulesUsage; - const ruleSearchOptions: RuleSearchParams = { - body: { - query: { - bool: { - filter: { - terms: { - 'alert.alertTypeId': [ - SIGNALS_ID, - EQL_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, - ], - }, - }, - }, - }, - }, - filter_path: [], - ignore_unavailable: true, - index: kibanaIndex, - size: MAX_RESULTS_WINDOW, - }; - - try { - const ruleResults = await esClient.search(ruleSearchOptions); - const detectionAlertsResp = (await esClient.search({ - index: `${signalsIndex}*`, - size: MAX_RESULTS_WINDOW, - body: { - aggs: { - detectionAlerts: { - terms: { field: ALERT_RULE_UUID }, - }, - }, - query: { - bool: { - filter: [ - { - range: { - '@timestamp': { - gte: 'now-24h', - lte: 'now', - }, - }, - }, - ], - }, - }, - }, - })) as AlertsAggregationResponse; - - const cases = await savedObjectClient.find({ - type: CASE_COMMENT_SAVED_OBJECT, - page: 1, - perPage: MAX_RESULTS_WINDOW, - namespaces: ['*'], - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: alert`, - }); - - // Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function. - const legacyRuleActions = - await savedObjectClient.find({ - type: legacyRuleActionsSavedObjectType, - page: 1, - perPage: MAX_RESULTS_WINDOW, - namespaces: ['*'], - }); - - const legacyNotificationRuleIds = legacyRuleActions.saved_objects.reduce( - (cache, legacyNotificationsObject) => { - const ruleRef = legacyNotificationsObject.references.find( - (reference) => reference.name === 'alert_0' && reference.type === 'alert' - ); - if (ruleRef != null) { - const enabled = legacyNotificationsObject.attributes.ruleThrottle !== 'no_actions'; - cache.set(ruleRef.id, { enabled }); - } - return cache; - }, - new Map() - ); - - const casesCache = cases.saved_objects.reduce((cache, { attributes: casesObject }) => { - const ruleId = casesObject.rule.id; - if (ruleId != null) { - const cacheCount = cache.get(ruleId); - if (cacheCount === undefined) { - cache.set(ruleId, 1); - } else { - cache.set(ruleId, cacheCount + 1); - } - } - return cache; - }, new Map()); - - const alertBuckets = detectionAlertsResp.aggregations?.detectionAlerts?.buckets ?? []; - - const alertsCache = new Map(); - alertBuckets.map((bucket) => alertsCache.set(bucket.key, bucket.doc_count)); - if (ruleResults.hits?.hits?.length > 0) { - const ruleObjects = ruleResults.hits.hits.map((hit) => { - const ruleId = hit._id.split(':')[1]; - const isElastic = isElasticRule(hit._source?.alert.tags); - - // Even if the legacy notification is set to "no_actions" we still count the rule as having a legacy notification that is not migrated yet. - const hasLegacyNotification = legacyNotificationRuleIds.get(ruleId) != null; - - // We only count a rule as having a notification and being "enabled" if it is _not_ set to "no_actions"/"muteAll" and it has at least one action within its array. - const hasNotification = - !hasLegacyNotification && - hit._source?.alert.actions != null && - hit._source?.alert.actions.length > 0 && - hit._source?.alert.muteAll !== true; - - return { - rule_name: hit._source?.alert.name, - rule_id: hit._source?.alert.params.ruleId, - rule_type: hit._source?.alert.params.type, - rule_version: Number(hit._source?.alert.params.version), - enabled: hit._source?.alert.enabled, - elastic_rule: isElastic, - created_on: hit._source?.alert.createdAt, - updated_on: hit._source?.alert.updatedAt, - alert_count_daily: alertsCache.get(ruleId) || 0, - cases_count_total: casesCache.get(ruleId) || 0, - has_legacy_notification: hasLegacyNotification, - has_notification: hasNotification, - } as DetectionRuleMetric; - }); - - // Only bring back rule detail on elastic prepackaged detection rules - const elasticRuleObjects = ruleObjects.filter((hit) => hit.elastic_rule === true); - - rulesUsage = ruleObjects.reduce((usage, rule) => { - return updateDetectionRuleUsage(rule, usage); - }, rulesUsage); - - return { - detection_rule_detail: elasticRuleObjects, - detection_rule_usage: rulesUsage, - }; - } - } catch (e) { - // ignore failure, usage will be zeroed - } - - return { - detection_rule_detail: [], - detection_rule_usage: rulesUsage, - }; -}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/get_initial_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/get_initial_usage.ts new file mode 100644 index 0000000000000..0d885aa3b142c --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/get_initial_usage.ts @@ -0,0 +1,25 @@ +/* + * 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 { DetectionMetrics } from './types'; + +import { getInitialMlJobUsage } from './ml_jobs/get_initial_usage'; +import { getInitialRulesUsage } from './rules/get_initial_usage'; + +/** + * Initial detection metrics initialized. + */ +export const getInitialDetectionMetrics = (): DetectionMetrics => ({ + ml_jobs: { + ml_job_usage: getInitialMlJobUsage(), + ml_job_metrics: [], + }, + detection_rules: { + detection_rule_detail: [], + detection_rule_usage: getInitialRulesUsage(), + }, +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts b/x-pack/plugins/security_solution/server/usage/detections/get_metrics.test.ts similarity index 66% rename from x-pack/plugins/security_solution/server/usage/detections/detections.test.ts rename to x-pack/plugins/security_solution/server/usage/detections/get_metrics.test.ts index 866fa226e2ecf..65929039bc104 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/get_metrics.test.ts @@ -5,48 +5,69 @@ * 2.0. */ +import type { DetectionMetrics } from './types'; + import { elasticsearchServiceMock, + loggingSystemMock, savedObjectsClientMock, } from '../../../../../../src/core/server/mocks'; import { mlServicesMock } from '../../lib/machine_learning/mocks'; -import { fetchDetectionsMetrics } from './index'; import { - getMockJobSummaryResponse, + getMockMlJobSummaryResponse, getMockListModulesResponse, getMockMlJobDetailsResponse, getMockMlJobStatsResponse, getMockMlDatafeedStatsResponse, getMockRuleSearchResponse, +} from './ml_jobs/get_metrics.mocks'; +import { getMockRuleAlertsResponse, - getMockAlertCasesResponse, -} from './detections.mocks'; -import { getInitialDetectionMetrics, initialDetectionRulesUsage } from './detection_rule_helpers'; -import { DetectionMetrics } from './types'; + getMockAlertCaseCommentsResponse, + getEmptySavedObjectResponse, +} from './rules/get_metrics.mocks'; +import { getInitialDetectionMetrics } from './get_initial_usage'; +import { getDetectionsMetrics } from './get_metrics'; +import { getInitialRulesUsage } from './rules/get_initial_usage'; describe('Detections Usage and Metrics', () => { - let esClientMock: ReturnType; - let mlMock: ReturnType; + let esClient: ReturnType; + let mlClient: ReturnType; let savedObjectsClient: ReturnType; - describe('getDetectionRuleMetrics()', () => { + describe('getRuleMetrics()', () => { beforeEach(() => { - esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser; - mlMock = mlServicesMock.createSetupContract(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + mlClient = mlServicesMock.createSetupContract(); savedObjectsClient = savedObjectsClientMock.create(); }); it('returns zeroed counts if calls are empty', async () => { - const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); + const logger = loggingSystemMock.createLogger(); + const result = await getDetectionsMetrics({ + signalsIndex: '', + esClient, + savedObjectsClient, + logger, + mlClient, + }); expect(result).toEqual(getInitialDetectionMetrics()); }); it('returns information with rule, alerts and cases', async () => { - esClientMock.search - .mockResponseOnce(getMockRuleSearchResponse()) - .mockResponseOnce(getMockRuleAlertsResponse(3400)); - savedObjectsClient.find.mockResolvedValue(getMockAlertCasesResponse()); - const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); + esClient.search.mockResponseOnce(getMockRuleAlertsResponse(3400)); + savedObjectsClient.find.mockResolvedValueOnce(getMockRuleSearchResponse()); + savedObjectsClient.find.mockResolvedValueOnce(getMockAlertCaseCommentsResponse()); + // Get empty saved object for legacy notification system. + savedObjectsClient.find.mockResolvedValueOnce(getEmptySavedObjectResponse()); + const logger = loggingSystemMock.createLogger(); + const result = await getDetectionsMetrics({ + signalsIndex: '', + esClient, + savedObjectsClient, + logger, + mlClient, + }); expect(result).toEqual({ ...getInitialDetectionMetrics(), @@ -68,7 +89,7 @@ describe('Detections Usage and Metrics', () => { }, ], detection_rule_usage: { - ...initialDetectionRulesUsage, + ...getInitialRulesUsage(), query: { enabled: 0, disabled: 1, @@ -95,18 +116,26 @@ describe('Detections Usage and Metrics', () => { }); it('returns information with on non elastic prebuilt rule', async () => { - esClientMock.search - .mockResponseOnce(getMockRuleSearchResponse('not_immutable')) - .mockResponseOnce(getMockRuleAlertsResponse(800)); - savedObjectsClient.find.mockResolvedValue(getMockAlertCasesResponse()); - const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); + esClient.search.mockResponseOnce(getMockRuleAlertsResponse(800)); + savedObjectsClient.find.mockResolvedValueOnce(getMockRuleSearchResponse('not_immutable')); + savedObjectsClient.find.mockResolvedValueOnce(getMockAlertCaseCommentsResponse()); + // Get empty saved object for legacy notification system. + savedObjectsClient.find.mockResolvedValueOnce(getEmptySavedObjectResponse()); + const logger = loggingSystemMock.createLogger(); + const result = await getDetectionsMetrics({ + signalsIndex: '', + esClient, + savedObjectsClient, + logger, + mlClient, + }); expect(result).toEqual({ ...getInitialDetectionMetrics(), detection_rules: { detection_rule_detail: [], // *should not* contain custom detection rule details detection_rule_usage: { - ...initialDetectionRulesUsage, + ...getInitialRulesUsage(), custom_total: { alerts: 800, cases: 1, @@ -133,11 +162,20 @@ describe('Detections Usage and Metrics', () => { }); it('returns information with rule, no alerts and no cases', async () => { - esClientMock.search - .mockResponseOnce(getMockRuleSearchResponse()) - .mockResponseOnce(getMockRuleAlertsResponse(0)); - savedObjectsClient.find.mockResolvedValue(getMockAlertCasesResponse()); - const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); + esClient.search.mockResponseOnce(getMockRuleAlertsResponse(0)); + savedObjectsClient.find.mockResolvedValueOnce(getMockRuleSearchResponse()); + savedObjectsClient.find.mockResolvedValueOnce(getMockAlertCaseCommentsResponse()); + // Get empty saved object for legacy notification system. + savedObjectsClient.find.mockResolvedValueOnce(getEmptySavedObjectResponse()); + + const logger = loggingSystemMock.createLogger(); + const result = await getDetectionsMetrics({ + signalsIndex: '', + esClient, + savedObjectsClient, + logger, + mlClient, + }); expect(result).toEqual({ ...getInitialDetectionMetrics(), @@ -159,7 +197,7 @@ describe('Detections Usage and Metrics', () => { }, ], detection_rule_usage: { - ...initialDetectionRulesUsage, + ...getInitialRulesUsage(), elastic_total: { alerts: 0, cases: 1, @@ -186,29 +224,38 @@ describe('Detections Usage and Metrics', () => { }); }); - describe('fetchDetectionsMetrics()', () => { + describe('getDetectionsMetrics()', () => { beforeEach(() => { - esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser; - mlMock = mlServicesMock.createSetupContract(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + mlClient = mlServicesMock.createSetupContract(); savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectResponse()); }); it('returns an empty array if there is no data', async () => { - mlMock.anomalyDetectorsProvider.mockReturnValue({ + mlClient.anomalyDetectorsProvider.mockReturnValue({ jobs: null, jobStats: null, - } as unknown as ReturnType); - const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); + } as unknown as ReturnType); + const logger = loggingSystemMock.createLogger(); + const result = await getDetectionsMetrics({ + signalsIndex: '', + esClient, + savedObjectsClient, + logger, + mlClient, + }); expect(result).toEqual(getInitialDetectionMetrics()); }); it('returns an ml job telemetry object from anomaly detectors provider', async () => { - const mockJobSummary = jest.fn().mockResolvedValue(getMockJobSummaryResponse()); + const logger = loggingSystemMock.createLogger(); + const mockJobSummary = jest.fn().mockResolvedValue(getMockMlJobSummaryResponse()); const mockListModules = jest.fn().mockResolvedValue(getMockListModulesResponse()); - mlMock.modulesProvider.mockReturnValue({ + mlClient.modulesProvider.mockReturnValue({ listModules: mockListModules, - } as unknown as ReturnType); - mlMock.jobServiceProvider.mockReturnValue({ + } as unknown as ReturnType); + mlClient.jobServiceProvider.mockReturnValue({ jobsSummary: mockJobSummary, }); const mockJobsResponse = jest.fn().mockResolvedValue(getMockMlJobDetailsResponse()); @@ -217,13 +264,19 @@ describe('Detections Usage and Metrics', () => { .fn() .mockResolvedValue(getMockMlDatafeedStatsResponse()); - mlMock.anomalyDetectorsProvider.mockReturnValue({ + mlClient.anomalyDetectorsProvider.mockReturnValue({ jobs: mockJobsResponse, jobStats: mockJobStatsResponse, datafeedStats: mockDatafeedStatsResponse, - } as unknown as ReturnType); + } as unknown as ReturnType); - const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); + const result = await getDetectionsMetrics({ + signalsIndex: '', + esClient, + savedObjectsClient, + logger, + mlClient, + }); expect(result).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/security_solution/server/usage/detections/get_metrics.ts b/x-pack/plugins/security_solution/server/usage/detections/get_metrics.ts new file mode 100644 index 0000000000000..258945fba662a --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/get_metrics.ts @@ -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 type { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'kibana/server'; +import type { MlPluginSetup } from '../../../../ml/server'; +import type { DetectionMetrics } from './types'; + +import { getMlJobMetrics } from './ml_jobs/get_metrics'; +import { getRuleMetrics } from './rules/get_metrics'; +import { getInitialRulesUsage } from './rules/get_initial_usage'; +import { getInitialMlJobUsage } from './ml_jobs/get_initial_usage'; + +export interface GetDetectionsMetricsOptions { + signalsIndex: string; + esClient: ElasticsearchClient; + savedObjectsClient: SavedObjectsClientContract; + logger: Logger; + mlClient: MlPluginSetup | undefined; +} + +export const getDetectionsMetrics = async ({ + signalsIndex, + esClient, + savedObjectsClient, + logger, + mlClient, +}: GetDetectionsMetricsOptions): Promise => { + const [mlJobMetrics, detectionRuleMetrics] = await Promise.allSettled([ + getMlJobMetrics({ mlClient, savedObjectsClient, logger }), + getRuleMetrics({ signalsIndex, esClient, savedObjectsClient, logger }), + ]); + + return { + ml_jobs: + mlJobMetrics.status === 'fulfilled' + ? mlJobMetrics.value + : { ml_job_metrics: [], ml_job_usage: getInitialMlJobUsage() }, + detection_rules: + detectionRuleMetrics.status === 'fulfilled' + ? detectionRuleMetrics.value + : { detection_rule_detail: [], detection_rule_usage: getInitialRulesUsage() }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/index.ts b/x-pack/plugins/security_solution/server/usage/detections/index.ts deleted file mode 100644 index a8d2ead83eec7..0000000000000 --- a/x-pack/plugins/security_solution/server/usage/detections/index.ts +++ /dev/null @@ -1,41 +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 { ElasticsearchClient, SavedObjectsClientContract } from '../../../../../../src/core/server'; -import { MlPluginSetup } from '../../../../ml/server'; -import { getDetectionRuleMetrics, initialDetectionRulesUsage } from './detection_rule_helpers'; -import { getMlJobMetrics, initialMlJobsUsage } from './detection_ml_helpers'; -import { DetectionMetrics } from './types'; - -import { INTERNAL_IMMUTABLE_KEY } from '../../../common/constants'; - -export const isElasticRule = (tags: string[] = []) => - tags.includes(`${INTERNAL_IMMUTABLE_KEY}:true`); - -export const fetchDetectionsMetrics = async ( - kibanaIndex: string, - signalsIndex: string, - esClient: ElasticsearchClient, - soClient: SavedObjectsClientContract, - mlClient: MlPluginSetup | undefined -): Promise => { - const [mlJobMetrics, detectionRuleMetrics] = await Promise.allSettled([ - getMlJobMetrics(mlClient, soClient), - getDetectionRuleMetrics(kibanaIndex, signalsIndex, esClient, soClient), - ]); - - return { - ml_jobs: - mlJobMetrics.status === 'fulfilled' - ? mlJobMetrics.value - : { ml_job_metrics: [], ml_job_usage: initialMlJobsUsage }, - detection_rules: - detectionRuleMetrics.status === 'fulfilled' - ? detectionRuleMetrics.value - : { detection_rule_detail: [], detection_rule_usage: initialDetectionRulesUsage }, - }; -}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_initial_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_initial_usage.ts new file mode 100644 index 0000000000000..6e3ab3124baf1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_initial_usage.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 type { MlJobUsage } from './types'; + +/** + * Default ml job usage count + */ +export const getInitialMlJobUsage = (): MlJobUsage => ({ + custom: { + enabled: 0, + disabled: 0, + }, + elastic: { + enabled: 0, + disabled: 0, + }, +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.mocks.ts similarity index 65% rename from x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts rename to x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.mocks.ts index e7c1384152c5a..a507a76e0c4f2 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.mocks.ts @@ -5,81 +5,8 @@ * 2.0. */ -import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; - -export const getMockJobSummaryResponse = () => [ - { - id: 'linux_anomalous_network_activity_ecs', - description: - 'SIEM Auditbeat: Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity (beta)', - groups: ['auditbeat', 'process', 'siem'], - processed_record_count: 141889, - memory_status: 'ok', - jobState: 'opened', - hasDatafeed: true, - datafeedId: 'datafeed-linux_anomalous_network_activity_ecs', - datafeedIndices: ['auditbeat-*'], - datafeedState: 'started', - latestTimestampMs: 1594085401911, - earliestTimestampMs: 1593054845656, - latestResultsTimestampMs: 1594085401911, - isSingleMetricViewerJob: true, - nodeName: 'node', - }, - { - id: 'linux_anomalous_network_port_activity_ecs', - description: - 'SIEM Auditbeat: Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity (beta)', - groups: ['auditbeat', 'process', 'siem'], - processed_record_count: 0, - memory_status: 'ok', - jobState: 'closed', - hasDatafeed: true, - datafeedId: 'datafeed-linux_anomalous_network_port_activity_ecs', - datafeedIndices: ['auditbeat-*'], - datafeedState: 'stopped', - isSingleMetricViewerJob: true, - }, - { - id: 'other_job', - description: 'a job that is custom', - groups: ['auditbeat', 'process', 'security'], - processed_record_count: 0, - memory_status: 'ok', - jobState: 'closed', - hasDatafeed: true, - datafeedId: 'datafeed-other', - datafeedIndices: ['auditbeat-*'], - datafeedState: 'stopped', - isSingleMetricViewerJob: true, - }, - { - id: 'another_job', - description: 'another job that is custom', - groups: ['auditbeat', 'process', 'security'], - processed_record_count: 0, - memory_status: 'ok', - jobState: 'opened', - hasDatafeed: true, - datafeedId: 'datafeed-another', - datafeedIndices: ['auditbeat-*'], - datafeedState: 'started', - isSingleMetricViewerJob: true, - }, - { - id: 'irrelevant_job', - description: 'a non-security job', - groups: ['auditbeat', 'process'], - processed_record_count: 0, - memory_status: 'ok', - jobState: 'opened', - hasDatafeed: true, - datafeedId: 'datafeed-another', - datafeedIndices: ['auditbeat-*'], - datafeedState: 'started', - isSingleMetricViewerJob: true, - }, -]; +import type { SavedObjectsFindResponse } from 'kibana/server'; +import type { RuleSearchResult } from '../../types'; export const getMockListModulesResponse = () => [ { @@ -162,6 +89,80 @@ export const getMockListModulesResponse = () => [ }, ]; +export const getMockMlJobSummaryResponse = () => [ + { + id: 'linux_anomalous_network_activity_ecs', + description: + 'SIEM Auditbeat: Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity (beta)', + groups: ['auditbeat', 'process', 'siem'], + processed_record_count: 141889, + memory_status: 'ok', + jobState: 'opened', + hasDatafeed: true, + datafeedId: 'datafeed-linux_anomalous_network_activity_ecs', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'started', + latestTimestampMs: 1594085401911, + earliestTimestampMs: 1593054845656, + latestResultsTimestampMs: 1594085401911, + isSingleMetricViewerJob: true, + nodeName: 'node', + }, + { + id: 'linux_anomalous_network_port_activity_ecs', + description: + 'SIEM Auditbeat: Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity (beta)', + groups: ['auditbeat', 'process', 'siem'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'closed', + hasDatafeed: true, + datafeedId: 'datafeed-linux_anomalous_network_port_activity_ecs', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'stopped', + isSingleMetricViewerJob: true, + }, + { + id: 'other_job', + description: 'a job that is custom', + groups: ['auditbeat', 'process', 'security'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'closed', + hasDatafeed: true, + datafeedId: 'datafeed-other', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'stopped', + isSingleMetricViewerJob: true, + }, + { + id: 'another_job', + description: 'another job that is custom', + groups: ['auditbeat', 'process', 'security'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'opened', + hasDatafeed: true, + datafeedId: 'datafeed-another', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'started', + isSingleMetricViewerJob: true, + }, + { + id: 'irrelevant_job', + description: 'a non-security job', + groups: ['auditbeat', 'process'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'opened', + hasDatafeed: true, + datafeedId: 'datafeed-another', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'started', + isSingleMetricViewerJob: true, + }, +]; + export const getMockMlJobDetailsResponse = () => ({ count: 20, jobs: [ @@ -291,177 +292,100 @@ export const getMockMlDatafeedStatsResponse = () => ({ export const getMockRuleSearchResponse = ( immutableTag: string = '__internal_immutable:true' -): SearchResponse => ({ - took: 2, - timed_out: false, - _shards: { +): SavedObjectsFindResponse => + ({ + page: 1, + per_page: 1_000, total: 1, - successful: 1, - skipped: 0, - failed: 0, - }, - hits: { - total: { - value: 1093, - relation: 'eq', - }, - max_score: 0, - hits: [ + saved_objects: [ { - _index: '.kibanaindex', - _id: 'alert:6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', - _score: 0, - _source: { - alert: { - name: 'Azure Diagnostic Settings Deletion', - tags: [ - 'Elastic', - 'Cloud', - 'Azure', - 'Continuous Monitoring', - 'SecOps', - 'Monitoring', - '__internal_rule_id:5370d4cd-2bb3-4d71-abf5-1e1d0ff5a2de', - `${immutableTag}`, + type: 'alert', + id: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', + namespaces: ['default'], + attributes: { + name: 'Azure Diagnostic Settings Deletion', + tags: [ + 'Elastic', + 'Cloud', + 'Azure', + 'Continuous Monitoring', + 'SecOps', + 'Monitoring', + '__internal_rule_id:5370d4cd-2bb3-4d71-abf5-1e1d0ff5a2de', + `${immutableTag}`, + ], + alertTypeId: 'siem.queryRule', + consumer: 'siem', + params: { + author: ['Elastic'], + description: + 'Identifies the deletion of diagnostic settings in Azure, which send platform logs and metrics to different destinations. An adversary may delete diagnostic settings in an attempt to evade defenses.', + ruleId: '5370d4cd-2bb3-4d71-abf5-1e1d0ff5a2de', + index: ['filebeat-*', 'logs-azure*'], + falsePositives: [ + 'Deletion of diagnostic settings may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Diagnostic settings deletion from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule.', ], - alertTypeId: 'siem.signals', - consumer: 'siem', - params: { - author: ['Elastic'], - description: - 'Identifies the deletion of diagnostic settings in Azure, which send platform logs and metrics to different destinations. An adversary may delete diagnostic settings in an attempt to evade defenses.', - ruleId: '5370d4cd-2bb3-4d71-abf5-1e1d0ff5a2de', - index: ['filebeat-*', 'logs-azure*'], - falsePositives: [ - 'Deletion of diagnostic settings may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Diagnostic settings deletion from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule.', - ], - from: 'now-25m', - immutable: true, - query: - 'event.dataset:azure.activitylogs and azure.activitylogs.operation_name:"MICROSOFT.INSIGHTS/DIAGNOSTICSETTINGS/DELETE" and event.outcome:(Success or success)', - language: 'kuery', - license: 'Elastic License v2', - outputIndex: '.siem-signals', - maxSignals: 100, - riskScore: 47, - timestampOverride: 'event.ingested', - to: 'now', - type: 'query', - references: [ - 'https://docs.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-settings', - ], - note: 'The Azure Filebeat module must be enabled to use this rule.', - version: 4, - exceptionsList: [], - }, - schedule: { - interval: '5m', - }, - enabled: false, - actions: [], - throttle: null, - notifyWhen: 'onActiveAlert', - apiKeyOwner: null, - apiKey: null, - createdBy: 'user', - updatedBy: 'user', - createdAt: '2021-03-23T17:15:59.634Z', - updatedAt: '2021-03-23T17:15:59.634Z', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'pending', - lastExecutionDate: '2021-03-23T17:15:59.634Z', - error: null, - }, - meta: { - versionApiKeyLastmodified: '8.0.0', + from: 'now-25m', + immutable: true, + query: + 'event.dataset:azure.activitylogs and azure.activitylogs.operation_name:"MICROSOFT.INSIGHTS/DIAGNOSTICSETTINGS/DELETE" and event.outcome:(Success or success)', + language: 'kuery', + license: 'Elastic License v2', + outputIndex: '.siem-signals', + maxSignals: 100, + riskScore: 47, + timestampOverride: 'event.ingested', + to: 'now', + type: 'query', + references: [ + 'https://docs.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-settings', + ], + note: 'The Azure Filebeat module must be enabled to use this rule.', + version: 4, + exceptionsList: [], + }, + schedule: { + interval: '5m', + }, + enabled: false, + actions: [], + throttle: null, + notifyWhen: 'onActiveAlert', + apiKeyOwner: null, + apiKey: '', + legacyId: null, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2021-03-23T17:15:59.634Z', + updatedAt: '2021-03-23T17:15:59.634Z', + muteAll: true, + mutedInstanceIds: [], + monitoring: { + execution: { + history: [], + calculated_metrics: { + success_ratio: 1, + p99: 7981, + p50: 1653, + p95: 6523.699999999996, + }, }, }, - type: 'alert', - references: [], - migrationVersion: { - alert: '7.13.0', + meta: { + versionApiKeyLastmodified: '8.2.0', }, - coreMigrationVersion: '8.0.0', - updated_at: '2021-03-23T17:15:59.634Z', + scheduledTaskId: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', }, - }, - ], - }, -}); - -export const getMockRuleAlertsResponse = (docCount: number): SearchResponse => ({ - took: 7, - timed_out: false, - _shards: { - total: 1, - successful: 1, - skipped: 0, - failed: 0, - }, - hits: { - total: { - value: 7322, - relation: 'eq', - }, - max_score: null, - hits: [], - }, - aggregations: { - detectionAlerts: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', - doc_count: docCount, - }, - ], - }, - }, -}); - -export const getMockAlertCasesResponse = () => ({ - page: 1, - per_page: 10000, - total: 4, - saved_objects: [ - { - type: 'cases-comments', - id: '3bb5cc10-9249-11eb-85b7-254c8af1a983', - attributes: { - type: 'alert', - alertId: '54802763917f521249c9f68d0d4be0c26cc538404c26dfed1ae7dcfa94ea2226', - index: '.siem-signals-default-000001', - rule: { - id: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', - name: 'Azure Diagnostic Settings Deletion', - }, - created_at: '2021-03-31T17:47:59.449Z', - created_by: { - email: '', - full_name: '', - username: '', + references: [], + migrationVersion: { + alert: '8.0.0', }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, + coreMigrationVersion: '8.2.0', + updated_at: '2021-03-23T17:15:59.634Z', + version: 'Wzk4NTQwLDNd', + score: 0, + sort: ['1644865254209', '19548'], }, - references: [ - { - type: 'cases', - name: 'associated-cases', - id: '3a3a4fa0-9249-11eb-85b7-254c8af1a983', - }, - ], - migrationVersion: {}, - coreMigrationVersion: '8.0.0', - updated_at: '2021-03-31T17:47:59.818Z', - version: 'WzI3MDIyODMsNF0=', - namespaces: ['default'], - score: 0, - }, - ], -}); + ], + // NOTE: We have to cast as "unknown" and then back to "RuleSearchResult" because "RuleSearchResult" isn't an exact type. See notes in the JSDocs fo that type. + } as unknown as SavedObjectsFindResponse); diff --git a/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.ts new file mode 100644 index 0000000000000..2eea42f28d953 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.ts @@ -0,0 +1,100 @@ +/* + * 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 { KibanaRequest, SavedObjectsClientContract, Logger } from 'kibana/server'; +import type { MlDatafeedStats, MlJob, MlPluginSetup } from '../../../../../ml/server'; +import type { MlJobMetric, MlJobUsageMetric } from './types'; + +import { isJobStarted } from '../../../../common/machine_learning/helpers'; +import { isSecurityJob } from '../../../../common/machine_learning/is_security_job'; +import { getInitialMlJobUsage } from './get_initial_usage'; +import { updateMlJobUsage } from './update_usage'; +import { getJobCorrelations } from './transform_utils/get_job_correlations'; + +export interface GetMlJobMetricsOptions { + mlClient: MlPluginSetup | undefined; + savedObjectsClient: SavedObjectsClientContract; + logger: Logger; +} + +export const getMlJobMetrics = async ({ + mlClient, + savedObjectsClient, + logger, +}: GetMlJobMetricsOptions): Promise => { + let jobsUsage = getInitialMlJobUsage(); + + if (mlClient == null) { + logger.debug( + 'Machine learning client is null/undefined, therefore not collecting telemetry from it' + ); + // early return if we don't have ml client + return { + ml_job_usage: getInitialMlJobUsage(), + ml_job_metrics: [], + }; + } + + try { + const fakeRequest = { headers: {} } as KibanaRequest; + + const modules = await mlClient.modulesProvider(fakeRequest, savedObjectsClient).listModules(); + const moduleJobs = modules.flatMap((module) => module.jobs); + const jobs = await mlClient.jobServiceProvider(fakeRequest, savedObjectsClient).jobsSummary(); + + jobsUsage = jobs.filter(isSecurityJob).reduce((usage, job) => { + const isElastic = moduleJobs.some((moduleJob) => moduleJob.id === job.id); + const isEnabled = isJobStarted(job.jobState, job.datafeedState); + + return updateMlJobUsage({ isElastic, isEnabled }, usage); + }, getInitialMlJobUsage()); + + const jobsType = 'security'; + const securityJobStats = await mlClient + .anomalyDetectorsProvider(fakeRequest, savedObjectsClient) + .jobStats(jobsType); + + const jobDetails = await mlClient + .anomalyDetectorsProvider(fakeRequest, savedObjectsClient) + .jobs(jobsType); + + const jobDetailsCache = new Map(); + jobDetails.jobs.forEach((detail) => jobDetailsCache.set(detail.job_id, detail)); + + const datafeedStats = await mlClient + .anomalyDetectorsProvider(fakeRequest, savedObjectsClient) + .datafeedStats(); + + const datafeedStatsCache = new Map(); + datafeedStats.datafeeds.forEach((datafeedStat) => + datafeedStatsCache.set(`${datafeedStat.datafeed_id}`, datafeedStat) + ); + + const jobMetrics = securityJobStats.jobs.map((stat) => { + const jobId = stat.job_id; + const jobDetail = jobDetailsCache.get(stat.job_id); + const datafeed = datafeedStatsCache.get(`datafeed-${jobId}`); + return getJobCorrelations({ stat, jobDetail, datafeed }); + }); + + return { + ml_job_usage: jobsUsage, + ml_job_metrics: jobMetrics, + }; + } catch (e) { + // ignore failure, usage will be zeroed. We don't log the message below as currently ML jobs when it does + // not have a "security" job will cause a throw. If this does not normally throw eventually on normal operations + // we should log a debug message like the following below to not unnecessarily worry users as this will not effect them: + // logger.debug( + // `Encountered unexpected condition in telemetry of message: ${e.message}, object: ${e}. Telemetry for "ml_jobs" will be skipped.` + // ); + return { + ml_job_usage: getInitialMlJobUsage(), + ml_job_metrics: [], + }; + } +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/transform_utils/get_job_correlations.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/transform_utils/get_job_correlations.ts new file mode 100644 index 0000000000000..59a23c5dc7bd1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/transform_utils/get_job_correlations.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 type { + MlDatafeedStats, + MlJob, + MlJobStats, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { MlJobMetric } from '../types'; + +export interface GetJobCorrelations { + stat: MlJobStats; + jobDetail: MlJob | undefined; + datafeed: MlDatafeedStats | undefined; +} + +export const getJobCorrelations = ({ + stat, + jobDetail, + datafeed, +}: GetJobCorrelations): MlJobMetric => { + return { + job_id: stat.job_id, + open_time: stat.open_time, + create_time: jobDetail?.create_time, + finished_time: jobDetail?.finished_time, + state: stat.state, + data_counts: { + bucket_count: stat.data_counts.bucket_count, + empty_bucket_count: stat.data_counts.empty_bucket_count, + input_bytes: stat.data_counts.input_bytes, + input_record_count: stat.data_counts.input_record_count, + last_data_time: stat.data_counts.last_data_time, + processed_record_count: stat.data_counts.processed_record_count, + }, + model_size_stats: { + bucket_allocation_failures_count: stat.model_size_stats.bucket_allocation_failures_count, + memory_status: stat.model_size_stats.memory_status, + model_bytes: stat.model_size_stats.model_bytes, + model_bytes_exceeded: stat.model_size_stats.model_bytes_exceeded, + model_bytes_memory_limit: stat.model_size_stats.model_bytes_memory_limit, + peak_model_bytes: stat.model_size_stats.peak_model_bytes, + }, + timing_stats: { + average_bucket_processing_time_ms: stat.timing_stats.average_bucket_processing_time_ms, + bucket_count: stat.timing_stats.bucket_count, + exponential_average_bucket_processing_time_ms: + stat.timing_stats.exponential_average_bucket_processing_time_ms, + exponential_average_bucket_processing_time_per_hour_ms: + stat.timing_stats.exponential_average_bucket_processing_time_per_hour_ms, + maximum_bucket_processing_time_ms: stat.timing_stats.maximum_bucket_processing_time_ms, + minimum_bucket_processing_time_ms: stat.timing_stats.minimum_bucket_processing_time_ms, + total_bucket_processing_time_ms: stat.timing_stats.total_bucket_processing_time_ms, + }, + datafeed: { + datafeed_id: datafeed?.datafeed_id, + state: datafeed?.state, + timing_stats: { + bucket_count: datafeed?.timing_stats.bucket_count, + exponential_average_search_time_per_hour_ms: + datafeed?.timing_stats.exponential_average_search_time_per_hour_ms, + search_count: datafeed?.timing_stats.search_count, + total_search_time_ms: datafeed?.timing_stats.total_search_time_ms, + }, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/types.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/types.ts new file mode 100644 index 0000000000000..c50fc3166977a --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/types.ts @@ -0,0 +1,56 @@ +/* + * 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 { + MlDataCounts, + MlDatafeedState, + MlDatafeedStats, + MlDatafeedTimingStats, + MlJob, + MlJobState, + MlJobStats, + MlJobTimingStats, + MlModelSizeStats, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +interface FeatureUsage { + enabled: number; + disabled: number; +} + +export interface MlJobUsage { + custom: FeatureUsage; + elastic: FeatureUsage; +} + +export interface MlJobMetric { + job_id: MlJobStats['job_id']; + create_time?: MlJob['create_time']; + finished_time?: MlJob['finished_time']; + open_time?: MlJobStats['open_time']; + state: MlJobState; + data_counts: Partial; + model_size_stats: Partial; + timing_stats: Partial; + datafeed: MlDataFeed; +} + +export interface MlJobUsageMetric { + ml_job_usage: MlJobUsage; + ml_job_metrics: MlJobMetric[]; +} + +export interface DetectionsMetric { + isElastic: boolean; + isEnabled: boolean; +} + +export interface MlDataFeed { + datafeed_id?: MlDatafeedStats['datafeed_id']; + state?: MlDatafeedState; + timing_stats: Partial; +} diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.test.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.test.ts similarity index 70% rename from x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.test.ts rename to x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.test.ts index 3ca0faeca7d36..9d0dc7c02e568 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.test.ts @@ -5,16 +5,17 @@ * 2.0. */ -import { initialMlJobsUsage, updateMlJobsUsage } from './detection_ml_helpers'; +import { getInitialMlJobUsage } from './get_initial_usage'; +import { updateMlJobUsage } from './update_usage'; describe('Security Machine Learning usage metrics', () => { describe('Updates metrics with job information', () => { it('Should update ML total for elastic rules', async () => { - const initialUsage = initialMlJobsUsage; + const initialUsage = getInitialMlJobUsage(); const isElastic = true; const isEnabled = true; - const updatedUsage = updateMlJobsUsage({ isElastic, isEnabled }, initialUsage); + const updatedUsage = updateMlJobUsage({ isElastic, isEnabled }, initialUsage); expect(updatedUsage).toEqual( expect.objectContaining({ @@ -31,11 +32,11 @@ describe('Security Machine Learning usage metrics', () => { }); it('Should update ML total for custom rules', async () => { - const initialUsage = initialMlJobsUsage; + const initialUsage = getInitialMlJobUsage(); const isElastic = false; const isEnabled = true; - const updatedUsage = updateMlJobsUsage({ isElastic, isEnabled }, initialUsage); + const updatedUsage = updateMlJobUsage({ isElastic, isEnabled }, initialUsage); expect(updatedUsage).toEqual( expect.objectContaining({ @@ -52,10 +53,9 @@ describe('Security Machine Learning usage metrics', () => { }); it('Should update ML total for both elastic and custom rules', async () => { - const initialUsage = initialMlJobsUsage; - - let updatedUsage = updateMlJobsUsage({ isElastic: true, isEnabled: true }, initialUsage); - updatedUsage = updateMlJobsUsage({ isElastic: false, isEnabled: true }, updatedUsage); + const initialUsage = getInitialMlJobUsage(); + let updatedUsage = updateMlJobUsage({ isElastic: true, isEnabled: true }, initialUsage); + updatedUsage = updateMlJobUsage({ isElastic: false, isEnabled: true }, updatedUsage); expect(updatedUsage).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.ts new file mode 100644 index 0000000000000..2306bfa051a3b --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.ts @@ -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 type { DetectionsMetric, MlJobUsage } from './types'; + +export const updateMlJobUsage = (jobMetric: DetectionsMetric, usage: MlJobUsage): MlJobUsage => { + const { isEnabled, isElastic } = jobMetric; + if (isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + enabled: usage.elastic.enabled + 1, + }, + }; + } else if (!isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + disabled: usage.elastic.disabled + 1, + }, + }; + } else if (isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + enabled: usage.custom.enabled + 1, + }, + }; + } else if (!isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + disabled: usage.custom.disabled + 1, + }, + }; + } else { + return usage; + } +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/get_initial_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/get_initial_usage.ts new file mode 100644 index 0000000000000..81ea7aec800e3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/get_initial_usage.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RulesTypeUsage } from './types'; + +/** + * Default detection rule usage count, split by type + elastic/custom + */ +export const getInitialRulesUsage = (): RulesTypeUsage => ({ + query: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, + threshold: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, + eql: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, + machine_learning: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, + threat_match: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, + elastic_total: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, + custom_total: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.mocks.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.mocks.ts new file mode 100644 index 0000000000000..1801d5bd67782 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.mocks.ts @@ -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 type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { SavedObjectsFindResponse } from 'kibana/server'; +import type { AlertAggs } from '../../types'; +import { CommentAttributes, CommentType } from '../../../../../cases/common/api/cases/comment'; + +export const getMockRuleAlertsResponse = (docCount: number): SearchResponse => ({ + took: 7, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 7322, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + buckets: { + after_key: { + detectionAlerts: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', + }, + buckets: [ + { + key: { + detectionAlerts: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', + }, + doc_count: docCount, + }, + ], + }, + }, +}); + +export const getMockAlertCaseCommentsResponse = (): SavedObjectsFindResponse< + Partial, + never +> => ({ + page: 1, + per_page: 10000, + total: 4, + saved_objects: [ + { + type: 'cases-comments', + id: '3bb5cc10-9249-11eb-85b7-254c8af1a983', + attributes: { + type: CommentType.alert, + alertId: '54802763917f521249c9f68d0d4be0c26cc538404c26dfed1ae7dcfa94ea2226', + index: '.siem-signals-default-000001', + rule: { + id: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', + name: 'Azure Diagnostic Settings Deletion', + }, + created_at: '2021-03-31T17:47:59.449Z', + created_by: { + email: '', + full_name: '', + username: '', + }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }, + references: [ + { + type: 'cases', + name: 'associated-cases', + id: '3a3a4fa0-9249-11eb-85b7-254c8af1a983', + }, + ], + migrationVersion: {}, + coreMigrationVersion: '8.0.0', + updated_at: '2021-03-31T17:47:59.818Z', + version: 'WzI3MDIyODMsNF0=', + namespaces: ['default'], + score: 0, + }, + ], +}); + +export const getEmptySavedObjectResponse = (): SavedObjectsFindResponse => ({ + page: 1, + per_page: 1_000, + total: 0, + saved_objects: [], +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.ts new file mode 100644 index 0000000000000..b202ea964301c --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.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 type { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'kibana/server'; +import type { RuleAdoption } from './types'; + +import { updateRuleUsage } from './update_usage'; +import { getDetectionRules } from '../../queries/get_detection_rules'; +import { getAlerts } from '../../queries/get_alerts'; +import { MAX_PER_PAGE, MAX_RESULTS_WINDOW } from '../../constants'; +import { getInitialRulesUsage } from './get_initial_usage'; +import { getCaseComments } from '../../queries/get_case_comments'; +import { getRuleIdToCasesMap } from './transform_utils/get_rule_id_to_cases_map'; +import { getAlertIdToCountMap } from './transform_utils/get_alert_id_to_count_map'; +import { getRuleIdToEnabledMap } from './transform_utils/get_rule_id_to_enabled_map'; +import { getRuleObjectCorrelations } from './transform_utils/get_rule_object_correlations'; + +// eslint-disable-next-line no-restricted-imports +import { legacyGetRuleActions } from '../../queries/legacy_get_rule_actions'; + +export interface GetRuleMetricsOptions { + signalsIndex: string; + esClient: ElasticsearchClient; + savedObjectsClient: SavedObjectsClientContract; + logger: Logger; +} + +export const getRuleMetrics = async ({ + signalsIndex, + esClient, + savedObjectsClient, + logger, +}: GetRuleMetricsOptions): Promise => { + try { + // gets rule saved objects + const ruleResults = await getDetectionRules({ + savedObjectsClient, + maxPerPage: MAX_PER_PAGE, + maxSize: MAX_RESULTS_WINDOW, + logger, + }); + + // early return if we don't have any detection rules then there is no need to query anything else + if (ruleResults.length === 0) { + return { + detection_rule_detail: [], + detection_rule_usage: getInitialRulesUsage(), + }; + } + + // gets the alerts data objects + const detectionAlertsRespPromise = getAlerts({ + esClient, + signalsIndex: `${signalsIndex}*`, + maxPerPage: MAX_PER_PAGE, + maxSize: MAX_RESULTS_WINDOW, + logger, + }); + + // gets cases saved objects + const caseCommentsPromise = getCaseComments({ + savedObjectsClient, + maxSize: MAX_PER_PAGE, + maxPerPage: MAX_RESULTS_WINDOW, + logger, + }); + + // gets the legacy rule actions to track legacy notifications. + const legacyRuleActionsPromise = legacyGetRuleActions({ + savedObjectsClient, + maxSize: MAX_PER_PAGE, + maxPerPage: MAX_RESULTS_WINDOW, + logger, + }); + + const [detectionAlertsResp, caseComments, legacyRuleActions] = await Promise.all([ + detectionAlertsRespPromise, + caseCommentsPromise, + legacyRuleActionsPromise, + ]); + + // create in-memory maps for correlation + const legacyNotificationRuleIds = getRuleIdToEnabledMap(legacyRuleActions); + const casesRuleIds = getRuleIdToCasesMap(caseComments); + const alertsCounts = getAlertIdToCountMap(detectionAlertsResp); + + // correlate the rule objects to the results + const rulesCorrelated = getRuleObjectCorrelations({ + ruleResults, + legacyNotificationRuleIds, + casesRuleIds, + alertsCounts, + }); + + // Only bring back rule detail on elastic prepackaged detection rules + const elasticRuleObjects = rulesCorrelated.filter((hit) => hit.elastic_rule === true); + + // calculate the rule usage + const rulesUsage = rulesCorrelated.reduce( + (usage, rule) => updateRuleUsage(rule, usage), + getInitialRulesUsage() + ); + + return { + detection_rule_detail: elasticRuleObjects, + detection_rule_usage: rulesUsage, + }; + } catch (e) { + // ignore failure, usage will be zeroed. We use debug mode to not unnecessarily worry users as this will not effect them. + logger.debug( + `Encountered unexpected condition in telemetry of message: ${e.message}, object: ${e}. Telemetry for "detection rules" being skipped.` + ); + return { + detection_rule_detail: [], + detection_rule_usage: getInitialRulesUsage(), + }; + } +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_alert_id_to_count_map.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_alert_id_to_count_map.ts new file mode 100644 index 0000000000000..ce569564273e4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_alert_id_to_count_map.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AlertBucket } from '../../../types'; + +export const getAlertIdToCountMap = (alerts: AlertBucket[]): Map => { + const alertsCache = new Map(); + alerts.map((bucket) => alertsCache.set(bucket.key.detectionAlerts, bucket.doc_count)); + return alertsCache; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_cases_map.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_cases_map.ts new file mode 100644 index 0000000000000..d7ce790be0750 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_cases_map.ts @@ -0,0 +1,30 @@ +/* + * 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 { SavedObjectsFindResult } from 'kibana/server'; +import type { CommentAttributes } from '../../../../../../cases/common/api/cases/comment'; + +export const getRuleIdToCasesMap = ( + cases: Array> +): Map => { + return cases.reduce((cache, { attributes: casesObject }) => { + if (casesObject.type === 'alert') { + const ruleId = casesObject.rule.id; + if (ruleId != null) { + const cacheCount = cache.get(ruleId); + if (cacheCount === undefined) { + cache.set(ruleId, 1); + } else { + cache.set(ruleId, cacheCount + 1); + } + } + return cache; + } else { + return cache; + } + }, new Map()); +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_enabled_map.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_enabled_map.ts new file mode 100644 index 0000000000000..b280d3a4ba17d --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_enabled_map.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 { SavedObjectsFindResult } from 'kibana/server'; +// eslint-disable-next-line no-restricted-imports +import type { LegacyIRuleActionsAttributesSavedObjectAttributes } from '../../../../lib/detection_engine/rule_actions/legacy_types'; + +export const getRuleIdToEnabledMap = ( + legacyRuleActions: Array< + SavedObjectsFindResult + > +): Map< + string, + { + enabled: boolean; + } +> => { + return legacyRuleActions.reduce((cache, legacyNotificationsObject) => { + const ruleRef = legacyNotificationsObject.references.find( + (reference) => reference.name === 'alert_0' && reference.type === 'alert' + ); + if (ruleRef != null) { + const enabled = legacyNotificationsObject.attributes.ruleThrottle !== 'no_actions'; + cache.set(ruleRef.id, { enabled }); + } + return cache; + }, new Map()); +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_object_correlations.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_object_correlations.ts new file mode 100644 index 0000000000000..0c364efe73bd9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_object_correlations.ts @@ -0,0 +1,62 @@ +/* + * 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 { SavedObjectsFindResult } from 'kibana/server'; +import type { RuleMetric } from '../types'; +import type { RuleSearchResult } from '../../../types'; + +import { isElasticRule } from '../../../queries/utils/is_elastic_rule'; + +export interface RuleObjectCorrelationsOptions { + ruleResults: Array>; + legacyNotificationRuleIds: Map< + string, + { + enabled: boolean; + } + >; + casesRuleIds: Map; + alertsCounts: Map; +} + +export const getRuleObjectCorrelations = ({ + ruleResults, + legacyNotificationRuleIds, + casesRuleIds, + alertsCounts, +}: RuleObjectCorrelationsOptions): RuleMetric[] => { + return ruleResults.map((result) => { + const ruleId = result.id; + const { attributes } = result; + const isElastic = isElasticRule(attributes.tags); + + // Even if the legacy notification is set to "no_actions" we still count the rule as having a legacy notification that is not migrated yet. + const hasLegacyNotification = legacyNotificationRuleIds.get(ruleId) != null; + + // We only count a rule as having a notification and being "enabled" if it is _not_ set to "no_actions"/"muteAll" and it has at least one action within its array. + const hasNotification = + !hasLegacyNotification && + attributes.actions != null && + attributes.actions.length > 0 && + attributes.muteAll !== true; + + return { + rule_name: attributes.name, + rule_id: attributes.params.ruleId, + rule_type: attributes.params.type, + rule_version: attributes.params.version, + enabled: attributes.enabled, + elastic_rule: isElastic, + created_on: attributes.createdAt, + updated_on: attributes.updatedAt, + alert_count_daily: alertsCounts.get(ruleId) || 0, + cases_count_total: casesRuleIds.get(ruleId) || 0, + has_legacy_notification: hasLegacyNotification, + has_notification: hasNotification, + }; + }); +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/types.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/types.ts new file mode 100644 index 0000000000000..54b3e6d6a0084 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/types.ts @@ -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. + */ + +export interface FeatureTypeUsage { + enabled: number; + disabled: number; + alerts: number; + cases: number; + legacy_notifications_enabled: number; + legacy_notifications_disabled: number; + notifications_enabled: number; + notifications_disabled: number; +} + +export interface RulesTypeUsage { + query: FeatureTypeUsage; + threshold: FeatureTypeUsage; + eql: FeatureTypeUsage; + machine_learning: FeatureTypeUsage; + threat_match: FeatureTypeUsage; + elastic_total: FeatureTypeUsage; + custom_total: FeatureTypeUsage; +} + +export interface RuleAdoption { + detection_rule_detail: RuleMetric[]; + detection_rule_usage: RulesTypeUsage; +} + +export interface RuleMetric { + rule_name: string; + rule_id: string; + rule_type: string; + rule_version: number; + enabled: boolean; + elastic_rule: boolean; + created_on: string; + updated_on: string; + alert_count_daily: number; + cases_count_total: number; + has_legacy_notification: boolean; + has_notification: boolean; +} diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.test.ts similarity index 92% rename from x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts rename to x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.test.ts index c19e7b18f9e72..d878d0a5145ab 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.test.ts @@ -5,8 +5,9 @@ * 2.0. */ -import { initialDetectionRulesUsage, updateDetectionRuleUsage } from './detection_rule_helpers'; -import { DetectionRuleMetric, DetectionRulesTypeUsage } from './types'; +import type { RuleMetric, RulesTypeUsage } from './types'; +import { updateRuleUsage } from './update_usage'; +import { getInitialRulesUsage } from './get_initial_usage'; interface StubRuleOptions { ruleType: string; @@ -26,7 +27,7 @@ const createStubRule = ({ caseCount, hasLegacyNotification, hasNotification, -}: StubRuleOptions): DetectionRuleMetric => ({ +}: StubRuleOptions): RuleMetric => ({ rule_name: 'rule-name', rule_id: 'id-123', rule_type: ruleType, @@ -53,10 +54,10 @@ describe('Detections Usage and Metrics', () => { hasLegacyNotification: false, hasNotification: false, }); - const usage = updateDetectionRuleUsage(stubRule, initialDetectionRulesUsage); + const usage = updateRuleUsage(stubRule, getInitialRulesUsage()); - expect(usage).toEqual({ - ...initialDetectionRulesUsage, + expect(usage).toEqual({ + ...getInitialRulesUsage(), elastic_total: { alerts: 1, cases: 1, @@ -127,14 +128,14 @@ describe('Detections Usage and Metrics', () => { hasNotification: false, }); - let usage = updateDetectionRuleUsage(stubEqlRule, initialDetectionRulesUsage); - usage = updateDetectionRuleUsage(stubQueryRuleOne, usage); - usage = updateDetectionRuleUsage(stubQueryRuleTwo, usage); - usage = updateDetectionRuleUsage(stubMachineLearningOne, usage); - usage = updateDetectionRuleUsage(stubMachineLearningTwo, usage); + let usage = updateRuleUsage(stubEqlRule, getInitialRulesUsage()); + usage = updateRuleUsage(stubQueryRuleOne, usage); + usage = updateRuleUsage(stubQueryRuleTwo, usage); + usage = updateRuleUsage(stubMachineLearningOne, usage); + usage = updateRuleUsage(stubMachineLearningTwo, usage); - expect(usage).toEqual({ - ...initialDetectionRulesUsage, + expect(usage).toEqual({ + ...getInitialRulesUsage(), custom_total: { alerts: 5, cases: 12, @@ -242,8 +243,8 @@ describe('Detections Usage and Metrics', () => { alertCount: 0, caseCount: 0, }); - const usage = updateDetectionRuleUsage(rule1, initialDetectionRulesUsage) as ReturnType< - typeof updateDetectionRuleUsage + const usage = updateRuleUsage(rule1, getInitialRulesUsage()) as ReturnType< + typeof updateRuleUsage > & { [key: string]: unknown }; expect(usage[ruleType]).toEqual( expect.objectContaining({ @@ -264,8 +265,8 @@ describe('Detections Usage and Metrics', () => { alertCount: 0, caseCount: 0, }); - const usageAddedByOne = updateDetectionRuleUsage(rule2, usage) as ReturnType< - typeof updateDetectionRuleUsage + const usageAddedByOne = updateRuleUsage(rule2, usage) as ReturnType< + typeof updateRuleUsage > & { [key: string]: unknown }; expect(usageAddedByOne[ruleType]).toEqual( diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.ts new file mode 100644 index 0000000000000..3aa3c3bbc29b0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.ts @@ -0,0 +1,85 @@ +/* + * 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 { RulesTypeUsage, RuleMetric } from './types'; +import { updateQueryUsage } from './usage_utils/update_query_usage'; +import { updateTotalUsage } from './usage_utils/update_total_usage'; + +export const updateRuleUsage = ( + detectionRuleMetric: RuleMetric, + usage: RulesTypeUsage +): RulesTypeUsage => { + let updatedUsage = usage; + if (detectionRuleMetric.rule_type === 'query') { + updatedUsage = { + ...usage, + query: updateQueryUsage({ + ruleType: detectionRuleMetric.rule_type, + usage, + detectionRuleMetric, + }), + }; + } else if (detectionRuleMetric.rule_type === 'threshold') { + updatedUsage = { + ...usage, + threshold: updateQueryUsage({ + ruleType: detectionRuleMetric.rule_type, + usage, + detectionRuleMetric, + }), + }; + } else if (detectionRuleMetric.rule_type === 'eql') { + updatedUsage = { + ...usage, + eql: updateQueryUsage({ + ruleType: detectionRuleMetric.rule_type, + usage, + detectionRuleMetric, + }), + }; + } else if (detectionRuleMetric.rule_type === 'machine_learning') { + updatedUsage = { + ...usage, + machine_learning: updateQueryUsage({ + ruleType: detectionRuleMetric.rule_type, + usage, + detectionRuleMetric, + }), + }; + } else if (detectionRuleMetric.rule_type === 'threat_match') { + updatedUsage = { + ...usage, + threat_match: updateQueryUsage({ + ruleType: detectionRuleMetric.rule_type, + usage, + detectionRuleMetric, + }), + }; + } + + if (detectionRuleMetric.elastic_rule) { + updatedUsage = { + ...updatedUsage, + elastic_total: updateTotalUsage({ + detectionRuleMetric, + updatedUsage, + totalType: 'elastic_total', + }), + }; + } else { + updatedUsage = { + ...updatedUsage, + custom_total: updateTotalUsage({ + detectionRuleMetric, + updatedUsage, + totalType: 'custom_total', + }), + }; + } + + return updatedUsage; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/get_notifications_enabled_disabled.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/get_notifications_enabled_disabled.ts new file mode 100644 index 0000000000000..aae3f3fe00d0f --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/get_notifications_enabled_disabled.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RuleMetric } from '../types'; + +export const getNotificationsEnabledDisabled = ( + detectionRuleMetric: RuleMetric +): { + legacyNotificationEnabled: boolean; + legacyNotificationDisabled: boolean; + notificationEnabled: boolean; + notificationDisabled: boolean; +} => { + return { + legacyNotificationEnabled: + detectionRuleMetric.has_legacy_notification && detectionRuleMetric.enabled, + legacyNotificationDisabled: + detectionRuleMetric.has_legacy_notification && !detectionRuleMetric.enabled, + notificationEnabled: detectionRuleMetric.has_notification && detectionRuleMetric.enabled, + notificationDisabled: detectionRuleMetric.has_notification && !detectionRuleMetric.enabled, + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_query_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_query_usage.ts new file mode 100644 index 0000000000000..7f40ceec21c8a --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_query_usage.ts @@ -0,0 +1,48 @@ +/* + * 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 { RulesTypeUsage, RuleMetric, FeatureTypeUsage } from '../types'; +import { getNotificationsEnabledDisabled } from './get_notifications_enabled_disabled'; + +export interface UpdateQueryUsageOptions { + ruleType: keyof RulesTypeUsage; + usage: RulesTypeUsage; + detectionRuleMetric: RuleMetric; +} + +export const updateQueryUsage = ({ + ruleType, + usage, + detectionRuleMetric, +}: UpdateQueryUsageOptions): FeatureTypeUsage => { + const { + legacyNotificationEnabled, + legacyNotificationDisabled, + notificationEnabled, + notificationDisabled, + } = getNotificationsEnabledDisabled(detectionRuleMetric); + return { + enabled: detectionRuleMetric.enabled ? usage[ruleType].enabled + 1 : usage[ruleType].enabled, + disabled: !detectionRuleMetric.enabled + ? usage[ruleType].disabled + 1 + : usage[ruleType].disabled, + alerts: usage[ruleType].alerts + detectionRuleMetric.alert_count_daily, + cases: usage[ruleType].cases + detectionRuleMetric.cases_count_total, + legacy_notifications_enabled: legacyNotificationEnabled + ? usage[ruleType].legacy_notifications_enabled + 1 + : usage[ruleType].legacy_notifications_enabled, + legacy_notifications_disabled: legacyNotificationDisabled + ? usage[ruleType].legacy_notifications_disabled + 1 + : usage[ruleType].legacy_notifications_disabled, + notifications_enabled: notificationEnabled + ? usage[ruleType].notifications_enabled + 1 + : usage[ruleType].notifications_enabled, + notifications_disabled: notificationDisabled + ? usage[ruleType].notifications_disabled + 1 + : usage[ruleType].notifications_disabled, + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_total_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_total_usage.ts new file mode 100644 index 0000000000000..ed0ff37e2a328 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_total_usage.ts @@ -0,0 +1,51 @@ +/* + * 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 { RulesTypeUsage, RuleMetric, FeatureTypeUsage } from '../types'; +import { getNotificationsEnabledDisabled } from './get_notifications_enabled_disabled'; + +export interface UpdateTotalUsageOptions { + detectionRuleMetric: RuleMetric; + updatedUsage: RulesTypeUsage; + totalType: 'custom_total' | 'elastic_total'; +} + +export const updateTotalUsage = ({ + detectionRuleMetric, + updatedUsage, + totalType, +}: UpdateTotalUsageOptions): FeatureTypeUsage => { + const { + legacyNotificationEnabled, + legacyNotificationDisabled, + notificationEnabled, + notificationDisabled, + } = getNotificationsEnabledDisabled(detectionRuleMetric); + + return { + enabled: detectionRuleMetric.enabled + ? updatedUsage[totalType].enabled + 1 + : updatedUsage[totalType].enabled, + disabled: !detectionRuleMetric.enabled + ? updatedUsage[totalType].disabled + 1 + : updatedUsage[totalType].disabled, + alerts: updatedUsage[totalType].alerts + detectionRuleMetric.alert_count_daily, + cases: updatedUsage[totalType].cases + detectionRuleMetric.cases_count_total, + legacy_notifications_enabled: legacyNotificationEnabled + ? updatedUsage[totalType].legacy_notifications_enabled + 1 + : updatedUsage[totalType].legacy_notifications_enabled, + legacy_notifications_disabled: legacyNotificationDisabled + ? updatedUsage[totalType].legacy_notifications_disabled + 1 + : updatedUsage[totalType].legacy_notifications_disabled, + notifications_enabled: notificationEnabled + ? updatedUsage[totalType].notifications_enabled + 1 + : updatedUsage[totalType].notifications_enabled, + notifications_disabled: notificationDisabled + ? updatedUsage[totalType].notifications_disabled + 1 + : updatedUsage[totalType].notifications_disabled, + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/types.ts b/x-pack/plugins/security_solution/server/usage/detections/types.ts index a7eb4c387d4ba..2895e5c6f8b9a 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/types.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/types.ts @@ -5,165 +5,10 @@ * 2.0. */ -interface RuleSearchBody { - query: { - bool: { - filter: { - terms: { [key: string]: string[] }; - }; - }; - }; -} - -export interface RuleSearchParams { - body: RuleSearchBody; - filter_path: string[]; - ignore_unavailable: boolean; - index: string; - size: number; -} - -export interface RuleSearchResult { - alert: { - name: string; - enabled: boolean; - tags: string[]; - createdAt: string; - updatedAt: string; - muteAll: boolean | undefined | null; - params: DetectionRuleParms; - actions: unknown[]; - }; -} - -export interface DetectionsMetric { - isElastic: boolean; - isEnabled: boolean; -} - -interface DetectionRuleParms { - ruleId: string; - version: string; - type: string; -} - -interface FeatureUsage { - enabled: number; - disabled: number; -} - -interface FeatureTypeUsage { - enabled: number; - disabled: number; - alerts: number; - cases: number; - legacy_notifications_enabled: number; - legacy_notifications_disabled: number; - notifications_enabled: number; - notifications_disabled: number; -} -export interface DetectionRulesTypeUsage { - query: FeatureTypeUsage; - threshold: FeatureTypeUsage; - eql: FeatureTypeUsage; - machine_learning: FeatureTypeUsage; - threat_match: FeatureTypeUsage; - elastic_total: FeatureTypeUsage; - custom_total: FeatureTypeUsage; -} - -export interface MlJobsUsage { - custom: FeatureUsage; - elastic: FeatureUsage; -} - -export interface DetectionsUsage { - ml_jobs: MlJobsUsage; -} +import type { MlJobUsageMetric } from './ml_jobs/types'; +import type { RuleAdoption } from './rules/types'; export interface DetectionMetrics { - ml_jobs: MlJobUsage; - detection_rules: DetectionRuleAdoption; -} - -export interface MlJobDataCount { - bucket_count: number; - empty_bucket_count: number; - input_bytes: number; - input_record_count: number; - last_data_time: number; - processed_record_count: number; -} - -export interface MlJobModelSize { - bucket_allocation_failures_count: number; - memory_status: string; - model_bytes: number; - model_bytes_exceeded: number; - model_bytes_memory_limit: number; - peak_model_bytes: number; -} - -export interface MlTimingStats { - bucket_count: number; - exponential_average_bucket_processing_time_ms: number; - exponential_average_bucket_processing_time_per_hour_ms: number; - maximum_bucket_processing_time_ms: number; - minimum_bucket_processing_time_ms: number; - total_bucket_processing_time_ms: number; -} - -export interface MlJobMetric { - job_id: string; - open_time: string; - state: string; - data_counts: MlJobDataCount; - model_size_stats: MlJobModelSize; - timing_stats: MlTimingStats; -} - -export interface DetectionRuleMetric { - rule_name: string; - rule_id: string; - rule_type: string; - rule_version: number; - enabled: boolean; - elastic_rule: boolean; - created_on: string; - updated_on: string; - alert_count_daily: number; - cases_count_total: number; - has_legacy_notification: boolean; - has_notification: boolean; -} - -export interface AlertsAggregationResponse { - hits: { - total: { value: number }; - }; - aggregations: { - [aggName: string]: { - buckets: Array<{ key: string; doc_count: number }>; - }; - }; -} - -export interface CasesSavedObject { - type: string; - alertId: string; - index: string; - rule: { - id: string | null; - name: string | null; - }; -} - -export interface MlJobUsage { - ml_job_usage: MlJobsUsage; - ml_job_metrics: MlJobMetric[]; -} - -export interface DetectionRuleAdoption { - detection_rule_detail: DetectionRuleMetric[]; - detection_rule_usage: DetectionRulesTypeUsage; + ml_jobs: MlJobUsageMetric; + detection_rules: RuleAdoption; } diff --git a/x-pack/plugins/security_solution/server/usage/get_internal_saved_objects_client.ts b/x-pack/plugins/security_solution/server/usage/get_internal_saved_objects_client.ts new file mode 100644 index 0000000000000..aea462ecf1fa6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/get_internal_saved_objects_client.ts @@ -0,0 +1,25 @@ +/* + * 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 { CoreSetup, SavedObjectsClientContract } from 'kibana/server'; + +import { SAVED_OBJECT_TYPES } from '../../../cases/common/constants'; +// eslint-disable-next-line no-restricted-imports +import { legacyRuleActionsSavedObjectType } from '../lib/detection_engine/rule_actions/legacy_saved_object_mappings'; + +export async function getInternalSavedObjectsClient( + core: CoreSetup +): Promise { + return core.getStartServices().then(async ([coreStart]) => { + // note: we include the "cases" and "alert" hidden types here otherwise we would not be able to query them. If at some point cases and alert is not considered a hidden type this can be removed + return coreStart.savedObjects.createInternalRepository([ + 'alert', + legacyRuleActionsSavedObjectType, + ...SAVED_OBJECT_TYPES, + ]) as unknown as SavedObjectsClientContract; + }); +} diff --git a/x-pack/plugins/security_solution/server/usage/queries/get_alerts.ts b/x-pack/plugins/security_solution/server/usage/queries/get_alerts.ts new file mode 100644 index 0000000000000..792ca28dcfba3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/queries/get_alerts.ts @@ -0,0 +1,115 @@ +/* + * 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 { OpenPointInTimeResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { + AggregationsCompositeAggregation, + SearchRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import type { ElasticsearchClient, Logger } from 'kibana/server'; +import { ALERT_RULE_UUID } from '@kbn/rule-data-utils'; +import type { AlertBucket, AlertAggs } from '../types'; + +export interface GetAlertsOptions { + esClient: ElasticsearchClient; + signalsIndex: string; + maxSize: number; + maxPerPage: number; + logger: Logger; +} + +export const getAlerts = async ({ + esClient, + signalsIndex, + maxSize, + maxPerPage, + logger, +}: GetAlertsOptions): Promise => { + // default is from looking at Kibana saved objects and online documentation + const keepAlive = '5m'; + + // create and assign an initial point in time + let pitId: OpenPointInTimeResponse['id'] = ( + await esClient.openPointInTime({ + index: signalsIndex, + keep_alive: keepAlive, + }) + ).id; + + let after: AggregationsCompositeAggregation['after']; + let buckets: AlertBucket[] = []; + let fetchMore = true; + while (fetchMore) { + const ruleSearchOptions: SearchRequest = { + aggs: { + buckets: { + composite: { + size: Math.min(maxPerPage, maxSize - buckets.length), + sources: [ + { + detectionAlerts: { + terms: { + field: ALERT_RULE_UUID, + }, + }, + }, + ], + after, + }, + }, + }, + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: 'now-24h', + lte: 'now', + }, + }, + }, + ], + }, + }, + track_total_hits: false, + sort: [{ _shard_doc: 'desc' }] as unknown as string[], // TODO: Remove this "unknown" once it is typed correctly https://github.com/elastic/elasticsearch-js/issues/1589 + pit: { id: pitId }, + size: 0, + }; + logger.debug( + `Getting alerts with point in time (PIT) query: ${JSON.stringify(ruleSearchOptions)}` + ); + const body = await esClient.search(ruleSearchOptions); + if (body.aggregations?.buckets?.buckets != null) { + buckets = [...buckets, ...body.aggregations.buckets.buckets]; + } + if (body.aggregations?.buckets?.after_key != null) { + after = { + detectionAlerts: body.aggregations.buckets.after_key.detectionAlerts, + }; + } + + fetchMore = + body.aggregations?.buckets?.buckets != null && + body.aggregations?.buckets?.buckets.length !== 0 && + buckets.length < maxSize; + if (body.pit_id != null) { + pitId = body.pit_id; + } + } + try { + await esClient.closePointInTime({ id: pitId }); + } catch (error) { + // Don't fail due to a bad point in time closure. We have seen failures in e2e tests during nominal operations. + logger.warn( + `Error trying to close point in time: "${pitId}", it will expire within "${keepAlive}". Error is: "${error}"` + ); + } + logger.debug(`Returning alerts response of length: "${buckets.length}"`); + return buckets; +}; diff --git a/x-pack/plugins/security_solution/server/usage/queries/get_case_comments.ts b/x-pack/plugins/security_solution/server/usage/queries/get_case_comments.ts new file mode 100644 index 0000000000000..0a6c7f2fc209a --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/queries/get_case_comments.ts @@ -0,0 +1,62 @@ +/* + * 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 { + SavedObjectsClientContract, + SavedObjectsFindResult, + Logger, + SavedObjectsCreatePointInTimeFinderOptions, +} from 'kibana/server'; + +import { CASE_COMMENT_SAVED_OBJECT } from '../../../../cases/common/constants'; +import type { CommentAttributes } from '../../../../cases/common/api/cases/comment'; + +export interface GetCasesOptions { + savedObjectsClient: SavedObjectsClientContract; + maxSize: number; + maxPerPage: number; + logger: Logger; +} + +export const getCaseComments = async ({ + savedObjectsClient, + maxSize, + maxPerPage, + logger, +}: GetCasesOptions): Promise>> => { + const query: SavedObjectsCreatePointInTimeFinderOptions = { + type: CASE_COMMENT_SAVED_OBJECT, + perPage: maxPerPage, + namespaces: ['*'], + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: alert`, + }; + logger.debug(`Getting cases with point in time (PIT) query:', ${JSON.stringify(query)}`); + const finder = savedObjectsClient.createPointInTimeFinder(query); + let responses: Array> = []; + for await (const response of finder.find()) { + const extra = responses.length + response.saved_objects.length - maxSize; + if (extra > 0) { + responses = [ + ...responses, + ...response.saved_objects.slice(-response.saved_objects.length, -extra), + ]; + } else { + responses = [...responses, ...response.saved_objects]; + } + } + + try { + finder.close(); + } catch (exception) { + // This is just a pre-caution in case the finder does a throw we don't want to blow up + // the response. We have seen this within e2e test containers but nothing happen in normal + // operational conditions which is why this try/catch is here. + } + + logger.debug(`Returning cases response of length: "${responses.length}"`); + return responses; +}; diff --git a/x-pack/plugins/security_solution/server/usage/queries/get_detection_rules.ts b/x-pack/plugins/security_solution/server/usage/queries/get_detection_rules.ts new file mode 100644 index 0000000000000..62f5691f73d07 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/queries/get_detection_rules.ts @@ -0,0 +1,82 @@ +/* + * 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 { + Logger, + SavedObjectsCreatePointInTimeFinderOptions, + SavedObjectsClientContract, + SavedObjectsFindResult, +} from 'kibana/server'; +import { + SIGNALS_ID, + EQL_RULE_TYPE_ID, + INDICATOR_RULE_TYPE_ID, + ML_RULE_TYPE_ID, + QUERY_RULE_TYPE_ID, + THRESHOLD_RULE_TYPE_ID, + SAVED_QUERY_RULE_TYPE_ID, +} from '@kbn/securitysolution-rules'; +import type { RuleSearchResult } from '../types'; + +export interface GetDetectionRulesOptions { + maxSize: number; + maxPerPage: number; + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; +} + +export const getDetectionRules = async ({ + maxSize, + maxPerPage, + logger, + savedObjectsClient, +}: GetDetectionRulesOptions): Promise>> => { + const filterAttribute = 'alert.attributes.alertTypeId'; + const filter = [ + `${filterAttribute}: ${SIGNALS_ID}`, + `${filterAttribute}: ${EQL_RULE_TYPE_ID}`, + `${filterAttribute}: ${ML_RULE_TYPE_ID}`, + `${filterAttribute}: ${QUERY_RULE_TYPE_ID}`, + `${filterAttribute}: ${SAVED_QUERY_RULE_TYPE_ID}`, + `${filterAttribute}: ${THRESHOLD_RULE_TYPE_ID}`, + `${filterAttribute}: ${INDICATOR_RULE_TYPE_ID}`, + ].join(' OR '); + + const query: SavedObjectsCreatePointInTimeFinderOptions = { + type: 'alert', + perPage: maxPerPage, + namespaces: ['*'], + filter, + }; + logger.debug( + `Getting detection rules with point in time (PIT) query:', ${JSON.stringify(query)}` + ); + const finder = savedObjectsClient.createPointInTimeFinder(query); + let responses: Array> = []; + for await (const response of finder.find()) { + const extra = responses.length + response.saved_objects.length - maxSize; + if (extra > 0) { + responses = [ + ...responses, + ...response.saved_objects.slice(-response.saved_objects.length, -extra), + ]; + } else { + responses = [...responses, ...response.saved_objects]; + } + } + + try { + finder.close(); + } catch (exception) { + // This is just a pre-caution in case the finder does a throw we don't want to blow up + // the response. We have seen this within e2e test containers but nothing happen in normal + // operational conditions which is why this try/catch is here. + } + + logger.debug(`Returning cases response of length: "${responses.length}"`); + return responses; +}; diff --git a/x-pack/plugins/security_solution/server/usage/queries/legacy_get_rule_actions.ts b/x-pack/plugins/security_solution/server/usage/queries/legacy_get_rule_actions.ts new file mode 100644 index 0000000000000..6d720bef7d822 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/queries/legacy_get_rule_actions.ts @@ -0,0 +1,73 @@ +/* + * 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 { + SavedObjectsClientContract, + SavedObjectsFindResult, + Logger, + SavedObjectsCreatePointInTimeFinderOptions, +} from 'kibana/server'; +// eslint-disable-next-line no-restricted-imports +import type { LegacyIRuleActionsAttributesSavedObjectAttributes } from '../../lib/detection_engine/rule_actions/legacy_types'; + +// eslint-disable-next-line no-restricted-imports +import { legacyRuleActionsSavedObjectType } from '../../lib/detection_engine/rule_actions/legacy_saved_object_mappings'; + +export interface LegacyGetRuleActionsOptions { + savedObjectsClient: SavedObjectsClientContract; + maxSize: number; + maxPerPage: number; + logger: Logger; +} + +/** + * Returns the legacy rule actions + * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove "legacyRuleActions" code including this function + */ +export const legacyGetRuleActions = async ({ + savedObjectsClient, + maxSize, + maxPerPage, + logger, +}: LegacyGetRuleActionsOptions) => { + const query: SavedObjectsCreatePointInTimeFinderOptions = { + type: legacyRuleActionsSavedObjectType, + perPage: maxPerPage, + namespaces: ['*'], + }; + logger.debug( + `Getting legacy rule actions with point in time (PIT) query:', ${JSON.stringify(query)}` + ); + const finder = + savedObjectsClient.createPointInTimeFinder( + query + ); + let responses: Array> = + []; + for await (const response of finder.find()) { + const extra = responses.length + response.saved_objects.length - maxSize; + if (extra > 0) { + responses = [ + ...responses, + ...response.saved_objects.slice(-response.saved_objects.length, -extra), + ]; + } else { + responses = [...responses, ...response.saved_objects]; + } + } + + try { + finder.close(); + } catch (exception) { + // This is just a pre-caution in case the finder does a throw we don't want to blow up + // the response. We have seen this within e2e test containers but nothing happen in normal + // operational conditions which is why this try/catch is here. + } + + logger.debug(`Returning legacy rule actions response of length: "${responses.length}"`); + return responses; +}; diff --git a/x-pack/plugins/security_solution/server/usage/queries/utils/fetch_hits_with_pit.ts b/x-pack/plugins/security_solution/server/usage/queries/utils/fetch_hits_with_pit.ts new file mode 100644 index 0000000000000..34dc545f9b8bf --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/queries/utils/fetch_hits_with_pit.ts @@ -0,0 +1,79 @@ +/* + * 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 { + OpenPointInTimeResponse, + SearchHit, + SortResults, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types'; +import type { ElasticsearchClient, Logger } from 'kibana/server'; + +export interface FetchWithPitOptions { + esClient: ElasticsearchClient; + index: string; + maxSize: number; + maxPerPage: number; + searchRequest: SearchRequest; + logger: Logger; +} + +export const fetchHitsWithPit = async ({ + esClient, + index, + searchRequest, + maxSize, + maxPerPage, + logger, +}: FetchWithPitOptions): Promise>> => { + // default is from looking at Kibana saved objects and online documentation + const keepAlive = '5m'; + + // create and assign an initial point in time + let pitId: OpenPointInTimeResponse['id'] = ( + await esClient.openPointInTime({ + index, + keep_alive: '5m', + }) + ).id; + + let searchAfter: SortResults | undefined; + let hits: Array> = []; + let fetchMore = true; + while (fetchMore) { + const ruleSearchOptions: SearchRequest = { + ...searchRequest, + track_total_hits: false, + search_after: searchAfter, + sort: [{ _shard_doc: 'desc' }] as unknown as string[], // TODO: Remove this "unknown" once it is typed correctly https://github.com/elastic/elasticsearch-js/issues/1589 + pit: { id: pitId }, + size: Math.min(maxPerPage, maxSize - hits.length), + }; + logger.debug( + `Getting hits with point in time (PIT) query of: ${JSON.stringify(ruleSearchOptions)}` + ); + const body = await esClient.search(ruleSearchOptions); + hits = [...hits, ...body.hits.hits]; + searchAfter = + body.hits.hits.length !== 0 ? body.hits.hits[body.hits.hits.length - 1].sort : undefined; + + fetchMore = searchAfter != null && body.hits.hits.length > 0 && hits.length < maxSize; + if (body.pit_id != null) { + pitId = body.pit_id; + } + } + try { + await esClient.closePointInTime({ id: pitId }); + } catch (error) { + // Don't fail due to a bad point in time closure. We have seen failures in e2e tests during nominal operations. + logger.warn( + `Error trying to close point in time: "${pitId}", it will expire within "${keepAlive}". Error is: "${error}"` + ); + } + logger.debug(`Returning hits with point in time (PIT) length of: ${hits.length}`); + return hits; +}; diff --git a/x-pack/plugins/security_solution/server/usage/queries/utils/is_elastic_rule.ts b/x-pack/plugins/security_solution/server/usage/queries/utils/is_elastic_rule.ts new file mode 100644 index 0000000000000..f08959702b290 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/queries/utils/is_elastic_rule.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { INTERNAL_IMMUTABLE_KEY } from '../../../../common/constants'; + +export const isElasticRule = (tags: string[] = []) => + tags.includes(`${INTERNAL_IMMUTABLE_KEY}:true`); diff --git a/x-pack/plugins/security_solution/server/usage/types.ts b/x-pack/plugins/security_solution/server/usage/types.ts index 1a3b5d1e2e29f..f591ffd8f422e 100644 --- a/x-pack/plugins/security_solution/server/usage/types.ts +++ b/x-pack/plugins/security_solution/server/usage/types.ts @@ -5,11 +5,49 @@ * 2.0. */ -import { CoreSetup } from 'src/core/server'; -import { SetupPlugins } from '../plugin'; +import type { CoreSetup, Logger } from 'src/core/server'; +import type { SanitizedAlert } from '../../../alerting/common/alert'; +import type { RuleParams } from '../lib/detection_engine/schemas/rule_schemas'; +import type { SetupPlugins } from '../plugin'; export type CollectorDependencies = { - kibanaIndex: string; signalsIndex: string; core: CoreSetup; + logger: Logger; } & Pick; + +export interface AlertBucket { + key: { + detectionAlerts: string; + }; + doc_count: number; +} + +export interface AlertAggs { + buckets?: { + after_key?: { + detectionAlerts: string; + }; + buckets: AlertBucket[]; + }; +} + +/** + * This type is _very_ similar to "RawRule". However, that type is not exposed in a non-restricted-path + * and it does not support generics well. Trying to use "RawRule" directly with TypeScript Omit does not work well. + * If at some point the rules client API supports cross spaces for gathering metrics, then we should remove our use + * of SavedObject types and this type below and instead natively use the rules client. + * + * NOTE: There are additional types not expressed below such as "apiKey" or there could be other slight differences + * but this will the easiest way to keep these in sync and I see other code that is similar to this pattern. + * {@see RawRule} + */ +export type RuleSearchResult = Omit< + SanitizedAlert, + 'createdBy' | 'updatedBy' | 'createdAt' | 'updatedAt' +> & { + createdBy: string | null; + updatedBy: string | null; + createdAt: string; + updatedAt: string; +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/all_types.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/all_types.ts index a8d473597a461..29baea4e4bd90 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/all_types.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/all_types.ts @@ -6,14 +6,14 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import type { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { createSignalsIndex, deleteAllAlerts, deleteSignalsIndex, getStats, } from '../../../../utils'; -import { getInitialDetectionMetrics } from '../../../../../../plugins/security_solution/server/usage/detections/detection_rule_helpers'; +import { getInitialDetectionMetrics } from '../../../../../../plugins/security_solution/server/usage/detections/get_initial_usage'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/detection_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/detection_rules.ts index 8a956d456edec..b93141a1ffe73 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/detection_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/detection_rules.ts @@ -6,12 +6,12 @@ */ import expect from '@kbn/expect'; -import { DetectionMetrics } from '../../../../../../plugins/security_solution/server/usage/detections/types'; -import { +import type { DetectionMetrics } from '../../../../../../plugins/security_solution/server/usage/detections/types'; +import type { ThreatMatchCreateSchema, ThresholdCreateSchema, } from '../../../../../../plugins/security_solution/common/detection_engine/schemas/request'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import type { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { createLegacyRuleAction, createNewAction, @@ -33,7 +33,7 @@ import { waitForSignalsToBePresent, updateRule, } from '../../../../utils'; -import { getInitialDetectionMetrics } from '../../../../../../plugins/security_solution/server/usage/detections/detection_rule_helpers'; +import { getInitialDetectionMetrics } from '../../../../../../plugins/security_solution/server/usage/detections/get_initial_usage'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { From c961b4b1f830169c6f76c9b06d3d4346c9771add Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 15 Feb 2022 12:58:11 -0700 Subject: [PATCH 28/39] [ML] Data Frame Analytics saved search creation functional tests (#125040) * create filter saved search test for each dfa job type * add search query test cases * temp comment of failing with insuff memory test case * remove problem case and update test text * Use downsampled farequote archive * remove unnecessary file Co-authored-by: Robert Oskamp --- .../classification_creation_saved_search.ts | 363 +++++++++++++++++ .../apps/ml/data_frame_analytics/index.ts | 3 + ...outlier_detection_creation_saved_search.ts | 377 ++++++++++++++++++ .../regression_creation_saved_search.ts | 333 ++++++++++++++++ x-pack/test/functional/apps/ml/index.ts | 1 + .../ml/farequote_small/data.json.gz | Bin 0 -> 63274 bytes .../ml/farequote_small/mappings.json | 48 +++ .../functional/services/ml/test_resources.ts | 34 +- .../services/ml/test_resources_data.ts | 5 - 9 files changed, 1147 insertions(+), 17 deletions(-) create mode 100644 x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts create mode 100644 x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts create mode 100644 x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts create mode 100644 x-pack/test/functional/es_archives/ml/farequote_small/data.json.gz create mode 100644 x-pack/test/functional/es_archives/ml/farequote_small/mappings.json diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts new file mode 100644 index 0000000000000..67550ae17a4b0 --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts @@ -0,0 +1,363 @@ +/* + * 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 { AnalyticsTableRowDetails } from '../../../services/ml/data_frame_analytics_table'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + const editedDescription = 'Edited description'; + + describe('classification saved search creation', function () { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote_small'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote_small', '@timestamp'); + await ml.testResources.createSavedSearchFarequoteLuceneIfNeeded('ft_farequote_small'); + await ml.testResources.createSavedSearchFarequoteKueryIfNeeded('ft_farequote_small'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + await ml.testResources.deleteSavedSearches(); + await ml.testResources.deleteIndexPatternByTitle('ft_farequote_small'); + }); + + const dateNow = Date.now(); + const testDataList = [ + { + suiteTitle: 'with lucene query', + jobType: 'classification', + jobId: `fq_saved_search_2_${dateNow}`, + jobDescription: 'Classification job based on a saved search with lucene query', + source: 'ft_farequote_lucene', + get destinationIndex(): string { + return `user-${this.jobId}`; + }, + runtimeFields: { + uppercase_airline: { + type: 'keyword', + script: 'emit(params._source.airline.toUpperCase())', + }, + }, + dependentVariable: 'airline', + trainingPercent: 20, + modelMemory: '20mb', + createIndexPattern: true, + expected: { + source: 'ft_farequote_small', + rocCurveColorState: [ + // tick/grid/axis + { color: '#DDDDDD', percentage: 38 }, + // line + { color: '#98A2B3', percentage: 7 }, + ], + runtimeFieldsEditorContent: ['{', ' "uppercase_airline": {', ' "type": "keyword",'], + row: { + memoryStatus: 'ok', + type: 'classification', + status: 'stopped', + progress: '100', + }, + rowDetails: { + jobDetails: [ + { + section: 'state', + expectedEntries: { + id: `fq_saved_search_2_${dateNow}`, + state: 'stopped', + data_counts: + '{"training_docs_count":320,"test_docs_count":1284,"skipped_docs_count":0}', + description: 'Classification job based on a saved search with lucene query', + }, + }, + { section: 'progress', expectedEntries: { Phase: '8/8' } }, + ], + } as AnalyticsTableRowDetails, + }, + }, + { + suiteTitle: 'with kuery query', + jobType: 'classification', + jobId: `fq_saved_search_3_${dateNow}`, + jobDescription: 'Classification job based on a saved search with kuery query', + source: 'ft_farequote_kuery', + get destinationIndex(): string { + return `user-${this.jobId}`; + }, + runtimeFields: { + uppercase_airline: { + type: 'keyword', + script: 'emit(params._source.airline.toUpperCase())', + }, + }, + dependentVariable: 'airline', + trainingPercent: 20, + modelMemory: '20mb', + createIndexPattern: true, + expected: { + source: 'ft_farequote_small', + rocCurveColorState: [ + // tick/grid/axis + { color: '#DDDDDD', percentage: 38 }, + // line + { color: '#98A2B3', percentage: 7 }, + ], + runtimeFieldsEditorContent: ['{', ' "uppercase_airline": {', ' "type": "keyword",'], + row: { + memoryStatus: 'ok', + type: 'classification', + status: 'stopped', + progress: '100', + }, + rowDetails: { + jobDetails: [ + { + section: 'state', + expectedEntries: { + id: `fq_saved_search_3_${dateNow}`, + state: 'stopped', + data_counts: + '{"training_docs_count":320,"test_docs_count":1283,"skipped_docs_count":0}', + description: 'Classification job based on a saved search with kuery query', + }, + }, + { section: 'progress', expectedEntries: { Phase: '8/8' } }, + ], + } as AnalyticsTableRowDetails, + }, + }, + ]; + + for (const testData of testDataList) { + describe(`${testData.suiteTitle}`, function () { + after(async () => { + await ml.api.deleteIndices(testData.destinationIndex); + await ml.testResources.deleteIndexPatternByTitle(testData.destinationIndex); + }); + + it('loads the data frame analytics wizard', async () => { + await ml.testExecution.logTestStep('loads the data frame analytics page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataFrameAnalytics(); + + await ml.testExecution.logTestStep('loads the source selection modal'); + + // Disable anti-aliasing to stabilize canvas image rendering assertions + await ml.commonUI.disableAntiAliasing(); + + await ml.dataFrameAnalytics.startAnalyticsCreation(); + + await ml.testExecution.logTestStep( + 'selects the source data and loads the job wizard page' + ); + await ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); + await ml.dataFrameAnalyticsCreation.assertConfigurationStepActive(); + }); + + it('navigates through the wizard and sets all needed fields', async () => { + await ml.testExecution.logTestStep('selects the job type'); + await ml.dataFrameAnalyticsCreation.assertJobTypeSelectExists(); + await ml.dataFrameAnalyticsCreation.selectJobType(testData.jobType); + + await ml.testExecution.logTestStep('displays the runtime mappings editor switch'); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingSwitchExists(); + + await ml.testExecution.logTestStep('enables the runtime mappings editor'); + await ml.dataFrameAnalyticsCreation.toggleRuntimeMappingsEditorSwitch(true); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingsEditorContent(['']); + + await ml.testExecution.logTestStep('sets runtime mappings'); + await ml.dataFrameAnalyticsCreation.setRuntimeMappingsEditorContent( + JSON.stringify(testData.runtimeFields) + ); + await ml.dataFrameAnalyticsCreation.applyRuntimeMappings(); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingsEditorContent( + testData.expected.runtimeFieldsEditorContent + ); + + await ml.testExecution.logTestStep('inputs the dependent variable'); + await ml.dataFrameAnalyticsCreation.assertDependentVariableInputExists(); + await ml.dataFrameAnalyticsCreation.selectDependentVariable(testData.dependentVariable); + + await ml.testExecution.logTestStep('inputs the training percent'); + await ml.dataFrameAnalyticsCreation.assertTrainingPercentInputExists(); + await ml.dataFrameAnalyticsCreation.setTrainingPercent(testData.trainingPercent); + + await ml.testExecution.logTestStep('displays the source data preview'); + await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewExists(); + + await ml.testExecution.logTestStep('displays the include fields selection'); + await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); + + await ml.testExecution.logTestStep('continues to the additional options step'); + await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); + + await ml.testExecution.logTestStep('accepts the suggested model memory limit'); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputPopulated(); + + await ml.testExecution.logTestStep('continues to the details step'); + await ml.dataFrameAnalyticsCreation.continueToDetailsStep(); + + await ml.testExecution.logTestStep('inputs the job id'); + await ml.dataFrameAnalyticsCreation.assertJobIdInputExists(); + await ml.dataFrameAnalyticsCreation.setJobId(testData.jobId); + + await ml.testExecution.logTestStep('inputs the job description'); + await ml.dataFrameAnalyticsCreation.assertJobDescriptionInputExists(); + await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); + + await ml.testExecution.logTestStep( + 'should default the set destination index to job id switch to true' + ); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdSwitchExists(); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdCheckState(true); + + await ml.testExecution.logTestStep('should input the destination index'); + await ml.dataFrameAnalyticsCreation.setDestIndexSameAsIdCheckState(false); + await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); + await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); + + await ml.testExecution.logTestStep('continues to the validation step'); + await ml.dataFrameAnalyticsCreation.continueToValidationStep(); + + await ml.testExecution.logTestStep('checks validation callouts exist'); + await ml.dataFrameAnalyticsCreation.assertValidationCalloutsExists(); + // Expect the follow callouts: + // - ✓ Dependent variable + // - ✓ Training percent + // - ✓ Top classes + // - ⚠ Analysis fields + await ml.dataFrameAnalyticsCreation.assertAllValidationCalloutsPresent(4); + + await ml.testExecution.logTestStep('continues to the create step'); + await ml.dataFrameAnalyticsCreation.continueToCreateStep(); + + await ml.testExecution.logTestStep('sets the create data view switch'); + await ml.dataFrameAnalyticsCreation.assertCreateIndexPatternSwitchExists(); + await ml.dataFrameAnalyticsCreation.setCreateIndexPatternSwitchState( + testData.createIndexPattern + ); + }); + + it('runs the analytics job and displays it correctly in the job list', async () => { + await ml.testExecution.logTestStep('creates and starts the analytics job'); + await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); + await ml.dataFrameAnalyticsCreation.assertStartJobCheckboxCheckState(true); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); + + await ml.testExecution.logTestStep('finishes analytics processing'); + await ml.dataFrameAnalytics.waitForAnalyticsCompletion(testData.jobId); + + await ml.testExecution.logTestStep('displays the analytics table'); + await ml.dataFrameAnalyticsCreation.navigateToJobManagementPage(); + await ml.dataFrameAnalytics.assertAnalyticsTableExists(); + + await ml.testExecution.logTestStep('displays the stats bar'); + await ml.dataFrameAnalytics.assertAnalyticsStatsBarExists(); + + await ml.testExecution.logTestStep('displays the created job in the analytics table'); + await ml.dataFrameAnalyticsTable.refreshAnalyticsTable(); + await ml.dataFrameAnalyticsTable.filterWithSearchString(testData.jobId, 1); + + await ml.testExecution.logTestStep( + 'displays details for the created job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: testData.jobDescription, + memoryStatus: testData.expected.row.memoryStatus, + sourceIndex: testData.expected.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + + await ml.dataFrameAnalyticsTable.assertAnalyticsRowDetails( + testData.jobId, + testData.expected.rowDetails + ); + }); + + it('edits the analytics job and displays it correctly in the job list', async () => { + await ml.testExecution.logTestStep( + 'should open the edit form for the created job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.openEditFlyout(testData.jobId); + + await ml.testExecution.logTestStep('should input the description in the edit form'); + await ml.dataFrameAnalyticsEdit.assertJobDescriptionEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobDescriptionEdit(editedDescription); + + await ml.testExecution.logTestStep( + 'should input the model memory limit in the edit form' + ); + await ml.dataFrameAnalyticsEdit.assertJobMmlEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobMmlEdit('21mb'); + + await ml.testExecution.logTestStep('should submit the edit job form'); + await ml.dataFrameAnalyticsEdit.updateAnalyticsJob(); + + await ml.testExecution.logTestStep( + 'displays details for the edited job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: editedDescription, + memoryStatus: testData.expected.row.memoryStatus, + sourceIndex: testData.expected.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + + await ml.testExecution.logTestStep( + 'creates the destination index and writes results to it' + ); + await ml.api.assertIndicesExist(testData.destinationIndex); + await ml.api.assertIndicesNotEmpty(testData.destinationIndex); + + await ml.testExecution.logTestStep('displays the results view for created job'); + await ml.dataFrameAnalyticsTable.openResultsView(testData.jobId); + await ml.dataFrameAnalyticsResults.assertClassificationEvaluatePanelElementsExists(); + await ml.dataFrameAnalyticsResults.assertClassificationTablePanelExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); + await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); + + await ml.testExecution.logTestStep('displays the ROC curve chart'); + await ml.commonUI.assertColorsInCanvasElement( + 'mlDFAnalyticsClassificationExplorationRocCurveChart', + testData.expected.rocCurveColorState, + ['#000000'], + undefined, + undefined, + // increased tolerance for ROC curve chart up from 10 to 20 + // since the returned colors vary quite a bit on each run. + 20 + ); + + await ml.commonUI.resetAntiAliasing(); + }); + + it('displays the analytics job in the map view', async () => { + await ml.testExecution.logTestStep('should open the map view for created job'); + await ml.navigation.navigateToDataFrameAnalytics(); + await ml.dataFrameAnalyticsTable.openMapView(testData.jobId); + await ml.dataFrameAnalyticsMap.assertMapElementsExists(); + await ml.dataFrameAnalyticsMap.assertJobMapTitle(testData.jobId); + }); + }); + } + }); +} diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts index e7b5df70c99a0..908e45daf7105 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts @@ -16,5 +16,8 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./classification_creation')); loadTestFile(require.resolve('./cloning')); loadTestFile(require.resolve('./feature_importance')); + loadTestFile(require.resolve('./regression_creation_saved_search')); + loadTestFile(require.resolve('./classification_creation_saved_search')); + loadTestFile(require.resolve('./outlier_detection_creation_saved_search')); }); } diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts new file mode 100644 index 0000000000000..861be18591a11 --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts @@ -0,0 +1,377 @@ +/* + * 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 { AnalyticsTableRowDetails } from '../../../services/ml/data_frame_analytics_table'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + const editedDescription = 'Edited description'; + + describe('outlier detection saved search creation', function () { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote_small'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote_small', '@timestamp'); + await ml.testResources.createSavedSearchFarequoteLuceneIfNeeded('ft_farequote_small'); + await ml.testResources.createSavedSearchFarequoteKueryIfNeeded('ft_farequote_small'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + await ml.testResources.deleteSavedSearches(); + await ml.testResources.deleteIndexPatternByTitle('ft_farequote_small'); + }); + + const dateNow = Date.now(); + const testDataList = [ + { + suiteTitle: 'with lucene query', + jobType: 'outlier_detection', + jobId: `fq_saved_search_2_${dateNow}`, + jobDescription: 'Outlier detection job based on a saved search with lucene query', + source: 'ft_farequote_lucene', + get destinationIndex(): string { + return `user-${this.jobId}`; + }, + runtimeFields: { + uppercase_airline: { + type: 'keyword', + script: 'emit(params._source.airline.toUpperCase())', + }, + }, + modelMemory: '65mb', + createIndexPattern: true, + expected: { + source: 'ft_farequote_small', + histogramCharts: [ + { chartAvailable: true, id: 'uppercase_airline', legend: '5 categories' }, + { chartAvailable: true, id: 'responsetime', legend: '4.91 - 171.08' }, + { chartAvailable: true, id: 'airline', legend: '5 categories' }, + ], + scatterplotMatrixColorsWizard: [ + // markers + { color: '#52B398', percentage: 15 }, + // grey boilerplate + { color: '#6A717D', percentage: 13 }, + ], + scatterplotMatrixColorStatsResults: [ + // red markers + { color: '#D98071', percentage: 1 }, + // tick/grid/axis, grey markers + { color: '#6A717D', percentage: 12 }, + { color: '#D3DAE6', percentage: 8 }, + { color: '#98A1B3', percentage: 12 }, + // anti-aliasing + { color: '#F5F7FA', percentage: 30 }, + ], + runtimeFieldsEditorContent: ['{', ' "uppercase_airline": {', ' "type": "keyword",'], + row: { + memoryStatus: 'ok', + type: 'outlier_detection', + status: 'stopped', + progress: '100', + }, + rowDetails: { + jobDetails: [ + { + section: 'state', + expectedEntries: { + id: `fq_saved_search_2_${dateNow}`, + state: 'stopped', + data_counts: + '{"training_docs_count":1604,"test_docs_count":0,"skipped_docs_count":0}', + description: 'Outlier detection job based on a saved search with lucene query', + }, + }, + { section: 'progress', expectedEntries: { Phase: '4/4' } }, + ], + } as AnalyticsTableRowDetails, + }, + }, + { + suiteTitle: 'with kuery query', + jobType: 'outlier_detection', + jobId: `fq_saved_search_3_${dateNow}`, + jobDescription: 'Outlier detection job based on a saved search with kuery query', + source: 'ft_farequote_kuery', + get destinationIndex(): string { + return `user-${this.jobId}`; + }, + runtimeFields: { + uppercase_airline: { + type: 'keyword', + script: 'emit(params._source.airline.toUpperCase())', + }, + }, + modelMemory: '65mb', + createIndexPattern: true, + expected: { + source: 'ft_farequote_small', + histogramCharts: [ + { chartAvailable: true, id: 'uppercase_airline', legend: '5 categories' }, + { chartAvailable: true, id: 'responsetime', legend: '9.91 - 171.08' }, + { chartAvailable: true, id: 'airline', legend: '5 categories' }, + ], + scatterplotMatrixColorsWizard: [ + // markers + { color: '#52B398', percentage: 15 }, + // grey boilerplate + { color: '#6A717D', percentage: 13 }, + ], + scatterplotMatrixColorStatsResults: [ + // red markers + { color: '#D98071', percentage: 1 }, + // tick/grid/axis, grey markers + { color: '#6A717D', percentage: 12 }, + { color: '#D3DAE6', percentage: 8 }, + { color: '#98A1B3', percentage: 12 }, + // anti-aliasing + { color: '#F5F7FA', percentage: 30 }, + ], + runtimeFieldsEditorContent: ['{', ' "uppercase_airline": {', ' "type": "keyword",'], + row: { + memoryStatus: 'ok', + type: 'outlier_detection', + status: 'stopped', + progress: '100', + }, + rowDetails: { + jobDetails: [ + { + section: 'state', + expectedEntries: { + id: `fq_saved_search_3_${dateNow}`, + state: 'stopped', + data_counts: + '{"training_docs_count":1603,"test_docs_count":0,"skipped_docs_count":0}', + description: 'Outlier detection job based on a saved search with kuery query', + }, + }, + { section: 'progress', expectedEntries: { Phase: '4/4' } }, + ], + } as AnalyticsTableRowDetails, + }, + }, + ]; + + for (const testData of testDataList) { + describe(`${testData.suiteTitle}`, function () { + after(async () => { + await ml.api.deleteIndices(testData.destinationIndex); + await ml.testResources.deleteIndexPatternByTitle(testData.destinationIndex); + }); + + it('loads the data frame analytics wizard', async () => { + await ml.testExecution.logTestStep('loads the data frame analytics page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataFrameAnalytics(); + + await ml.testExecution.logTestStep('loads the source selection modal'); + + // Disable anti-aliasing to stabilize canvas image rendering assertions + await ml.commonUI.disableAntiAliasing(); + + await ml.dataFrameAnalytics.startAnalyticsCreation(); + + await ml.testExecution.logTestStep( + 'selects the source data and loads the job wizard page' + ); + await ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); + await ml.dataFrameAnalyticsCreation.assertConfigurationStepActive(); + }); + + it('navigates through the wizard and sets all needed fields', async () => { + await ml.testExecution.logTestStep('selects the job type'); + await ml.dataFrameAnalyticsCreation.assertJobTypeSelectExists(); + await ml.dataFrameAnalyticsCreation.selectJobType(testData.jobType); + + await ml.testExecution.logTestStep('displays the runtime mappings editor switch'); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingSwitchExists(); + + await ml.testExecution.logTestStep('enables the runtime mappings editor'); + await ml.dataFrameAnalyticsCreation.toggleRuntimeMappingsEditorSwitch(true); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingsEditorContent(['']); + + await ml.testExecution.logTestStep('sets runtime mappings'); + await ml.dataFrameAnalyticsCreation.setRuntimeMappingsEditorContent( + JSON.stringify(testData.runtimeFields) + ); + await ml.dataFrameAnalyticsCreation.applyRuntimeMappings(); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingsEditorContent( + testData.expected.runtimeFieldsEditorContent + ); + + await ml.testExecution.logTestStep('does not display the dependent variable input'); + await ml.dataFrameAnalyticsCreation.assertDependentVariableInputMissing(); + + await ml.testExecution.logTestStep('does not display the training percent input'); + await ml.dataFrameAnalyticsCreation.assertTrainingPercentInputMissing(); + + await ml.testExecution.logTestStep('displays the source data preview'); + await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewExists(); + + await ml.testExecution.logTestStep('enables the source data preview histogram charts'); + await ml.dataFrameAnalyticsCreation.enableSourceDataPreviewHistogramCharts(true); + + await ml.testExecution.logTestStep('displays the source data preview histogram charts'); + await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewHistogramCharts( + testData.expected.histogramCharts + ); + + await ml.testExecution.logTestStep('displays the include fields selection'); + await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); + + await ml.testExecution.logTestStep('continues to the additional options step'); + await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); + + await ml.testExecution.logTestStep('accepts the suggested model memory limit'); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputPopulated(); + + await ml.testExecution.logTestStep('continues to the details step'); + await ml.dataFrameAnalyticsCreation.continueToDetailsStep(); + + await ml.testExecution.logTestStep('inputs the job id'); + await ml.dataFrameAnalyticsCreation.assertJobIdInputExists(); + await ml.dataFrameAnalyticsCreation.setJobId(testData.jobId); + + await ml.testExecution.logTestStep('inputs the job description'); + await ml.dataFrameAnalyticsCreation.assertJobDescriptionInputExists(); + await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); + + await ml.testExecution.logTestStep( + 'should default the set destination index to job id switch to true' + ); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdSwitchExists(); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdCheckState(true); + + await ml.testExecution.logTestStep('should input the destination index'); + await ml.dataFrameAnalyticsCreation.setDestIndexSameAsIdCheckState(false); + await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); + await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); + + await ml.testExecution.logTestStep('continues to the validation step'); + await ml.dataFrameAnalyticsCreation.continueToValidationStep(); + + await ml.testExecution.logTestStep('checks validation callouts exist'); + await ml.dataFrameAnalyticsCreation.assertValidationCalloutsExists(); + await ml.dataFrameAnalyticsCreation.assertAllValidationCalloutsPresent(1); + + await ml.testExecution.logTestStep('continues to the create step'); + await ml.dataFrameAnalyticsCreation.continueToCreateStep(); + + await ml.testExecution.logTestStep('sets the create data view switch'); + await ml.dataFrameAnalyticsCreation.assertCreateIndexPatternSwitchExists(); + await ml.dataFrameAnalyticsCreation.setCreateIndexPatternSwitchState( + testData.createIndexPattern + ); + }); + + it('runs the analytics job and displays it correctly in the job list', async () => { + await ml.testExecution.logTestStep('creates and starts the analytics job'); + await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); + await ml.dataFrameAnalyticsCreation.assertStartJobCheckboxCheckState(true); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); + + await ml.testExecution.logTestStep('finishes analytics processing'); + await ml.dataFrameAnalytics.waitForAnalyticsCompletion(testData.jobId); + + await ml.testExecution.logTestStep('displays the analytics table'); + await ml.dataFrameAnalyticsCreation.navigateToJobManagementPage(); + await ml.dataFrameAnalytics.assertAnalyticsTableExists(); + + await ml.testExecution.logTestStep('displays the stats bar'); + await ml.dataFrameAnalytics.assertAnalyticsStatsBarExists(); + + await ml.testExecution.logTestStep('displays the created job in the analytics table'); + await ml.dataFrameAnalyticsTable.refreshAnalyticsTable(); + await ml.dataFrameAnalyticsTable.filterWithSearchString(testData.jobId, 1); + + await ml.testExecution.logTestStep( + 'displays details for the created job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: testData.jobDescription, + memoryStatus: testData.expected.row.memoryStatus, + sourceIndex: testData.expected.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + + await ml.dataFrameAnalyticsTable.assertAnalyticsRowDetails( + testData.jobId, + testData.expected.rowDetails + ); + }); + + it('edits the analytics job and displays it correctly in the job list', async () => { + await ml.testExecution.logTestStep( + 'should open the edit form for the created job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.openEditFlyout(testData.jobId); + + await ml.testExecution.logTestStep('should input the description in the edit form'); + await ml.dataFrameAnalyticsEdit.assertJobDescriptionEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobDescriptionEdit(editedDescription); + + await ml.testExecution.logTestStep( + 'should input the model memory limit in the edit form' + ); + await ml.dataFrameAnalyticsEdit.assertJobMmlEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobMmlEdit('21mb'); + + await ml.testExecution.logTestStep('should submit the edit job form'); + await ml.dataFrameAnalyticsEdit.updateAnalyticsJob(); + + await ml.testExecution.logTestStep( + 'displays details for the edited job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: editedDescription, + memoryStatus: testData.expected.row.memoryStatus, + sourceIndex: testData.expected.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + + await ml.testExecution.logTestStep( + 'creates the destination index and writes results to it' + ); + await ml.api.assertIndicesExist(testData.destinationIndex); + await ml.api.assertIndicesNotEmpty(testData.destinationIndex); + + await ml.testExecution.logTestStep('displays the results view for created job'); + await ml.dataFrameAnalyticsTable.openResultsView(testData.jobId); + await ml.dataFrameAnalyticsResults.assertOutlierTablePanelExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); + await ml.dataFrameAnalyticsResults.assertFeatureInfluenceCellNotEmpty(); + + await ml.commonUI.resetAntiAliasing(); + }); + + it('displays the analytics job in the map view', async () => { + await ml.testExecution.logTestStep('should open the map view for created job'); + await ml.navigation.navigateToDataFrameAnalytics(); + await ml.dataFrameAnalyticsTable.openMapView(testData.jobId); + await ml.dataFrameAnalyticsMap.assertMapElementsExists(); + await ml.dataFrameAnalyticsMap.assertJobMapTitle(testData.jobId); + }); + }); + } + }); +} diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts new file mode 100644 index 0000000000000..e22c4908486d1 --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts @@ -0,0 +1,333 @@ +/* + * 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 { AnalyticsTableRowDetails } from '../../../services/ml/data_frame_analytics_table'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + const editedDescription = 'Edited description'; + + describe('regression saved search creation', function () { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote_small'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote_small', '@timestamp'); + await ml.testResources.createSavedSearchFarequoteLuceneIfNeeded('ft_farequote_small'); + await ml.testResources.createSavedSearchFarequoteKueryIfNeeded('ft_farequote_small'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + await ml.testResources.deleteSavedSearches(); + await ml.testResources.deleteIndexPatternByTitle('ft_farequote_small'); + }); + + const dateNow = Date.now(); + const testDataList = [ + { + suiteTitle: 'with lucene query', + jobType: 'regression', + jobId: `fq_saved_search_2_${dateNow}`, + jobDescription: 'Regression job based on a saved search with lucene query', + source: 'ft_farequote_lucene', + get destinationIndex(): string { + return `user-${this.jobId}`; + }, + runtimeFields: { + uppercase_airline: { + type: 'keyword', + script: 'emit(params._source.airline.toUpperCase())', + }, + }, + dependentVariable: 'responsetime', + trainingPercent: 20, + modelMemory: '20mb', + createIndexPattern: true, + expected: { + source: 'ft_farequote_small', + runtimeFieldsEditorContent: ['{', ' "uppercase_airline": {', ' "type": "keyword",'], + row: { + memoryStatus: 'ok', + type: 'regression', + status: 'stopped', + progress: '100', + }, + rowDetails: { + jobDetails: [ + { + section: 'state', + expectedEntries: { + id: `fq_saved_search_2_${dateNow}`, + state: 'stopped', + data_counts: + '{"training_docs_count":320,"test_docs_count":1284,"skipped_docs_count":0}', + description: 'Regression job based on a saved search with lucene query', + }, + }, + { section: 'progress', expectedEntries: { Phase: '8/8' } }, + ], + } as AnalyticsTableRowDetails, + }, + }, + { + suiteTitle: 'with kuery query', + jobType: 'regression', + jobId: `fq_saved_search_3_${dateNow}`, + jobDescription: 'Regression job based on a saved search with kuery query', + source: 'ft_farequote_kuery', + get destinationIndex(): string { + return `user-${this.jobId}`; + }, + runtimeFields: { + uppercase_airline: { + type: 'keyword', + script: 'emit(params._source.airline.toUpperCase())', + }, + }, + dependentVariable: 'responsetime', + trainingPercent: 20, + modelMemory: '20mb', + createIndexPattern: true, + expected: { + source: 'ft_farequote_small', + runtimeFieldsEditorContent: ['{', ' "uppercase_airline": {', ' "type": "keyword",'], + row: { + memoryStatus: 'ok', + type: 'regression', + status: 'stopped', + progress: '100', + }, + rowDetails: { + jobDetails: [ + { + section: 'state', + expectedEntries: { + id: `fq_saved_search_3_${dateNow}`, + state: 'stopped', + data_counts: + '{"training_docs_count":320,"test_docs_count":1283,"skipped_docs_count":0}', + description: 'Regression job based on a saved search with kuery query', + }, + }, + { section: 'progress', expectedEntries: { Phase: '8/8' } }, + ], + } as AnalyticsTableRowDetails, + }, + }, + ]; + + for (const testData of testDataList) { + describe(`${testData.suiteTitle}`, function () { + after(async () => { + await ml.api.deleteIndices(testData.destinationIndex); + await ml.testResources.deleteIndexPatternByTitle(testData.destinationIndex); + }); + + it('loads the data frame analytics wizard', async () => { + await ml.testExecution.logTestStep('loads the data frame analytics page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataFrameAnalytics(); + + await ml.testExecution.logTestStep('loads the source selection modal'); + + // Disable anti-aliasing to stabilize canvas image rendering assertions + await ml.commonUI.disableAntiAliasing(); + + await ml.dataFrameAnalytics.startAnalyticsCreation(); + + await ml.testExecution.logTestStep( + 'selects the source data and loads the job wizard page' + ); + await ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); + await ml.dataFrameAnalyticsCreation.assertConfigurationStepActive(); + }); + + it('navigates through the wizard and sets all needed fields', async () => { + await ml.testExecution.logTestStep('selects the job type'); + await ml.dataFrameAnalyticsCreation.assertJobTypeSelectExists(); + await ml.dataFrameAnalyticsCreation.selectJobType(testData.jobType); + + await ml.testExecution.logTestStep('displays the runtime mappings editor switch'); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingSwitchExists(); + + await ml.testExecution.logTestStep('enables the runtime mappings editor'); + await ml.dataFrameAnalyticsCreation.toggleRuntimeMappingsEditorSwitch(true); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingsEditorContent(['']); + + await ml.testExecution.logTestStep('sets runtime mappings'); + await ml.dataFrameAnalyticsCreation.setRuntimeMappingsEditorContent( + JSON.stringify(testData.runtimeFields) + ); + await ml.dataFrameAnalyticsCreation.applyRuntimeMappings(); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingsEditorContent( + testData.expected.runtimeFieldsEditorContent + ); + + await ml.testExecution.logTestStep('inputs the dependent variable'); + await ml.dataFrameAnalyticsCreation.assertDependentVariableInputExists(); + await ml.dataFrameAnalyticsCreation.selectDependentVariable(testData.dependentVariable); + + await ml.testExecution.logTestStep('inputs the training percent'); + await ml.dataFrameAnalyticsCreation.assertTrainingPercentInputExists(); + await ml.dataFrameAnalyticsCreation.setTrainingPercent(testData.trainingPercent); + + await ml.testExecution.logTestStep('displays the source data preview'); + await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewExists(); + + await ml.testExecution.logTestStep('displays the include fields selection'); + await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); + + await ml.testExecution.logTestStep('continues to the additional options step'); + await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); + + await ml.testExecution.logTestStep('accepts the suggested model memory limit'); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputPopulated(); + + await ml.testExecution.logTestStep('continues to the details step'); + await ml.dataFrameAnalyticsCreation.continueToDetailsStep(); + + await ml.testExecution.logTestStep('inputs the job id'); + await ml.dataFrameAnalyticsCreation.assertJobIdInputExists(); + await ml.dataFrameAnalyticsCreation.setJobId(testData.jobId); + + await ml.testExecution.logTestStep('inputs the job description'); + await ml.dataFrameAnalyticsCreation.assertJobDescriptionInputExists(); + await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); + + await ml.testExecution.logTestStep( + 'should default the set destination index to job id switch to true' + ); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdSwitchExists(); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdCheckState(true); + + await ml.testExecution.logTestStep('should input the destination index'); + await ml.dataFrameAnalyticsCreation.setDestIndexSameAsIdCheckState(false); + await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); + await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); + + await ml.testExecution.logTestStep('continues to the validation step'); + await ml.dataFrameAnalyticsCreation.continueToValidationStep(); + + await ml.testExecution.logTestStep('checks validation callouts exist'); + await ml.dataFrameAnalyticsCreation.assertValidationCalloutsExists(); + await ml.dataFrameAnalyticsCreation.assertAllValidationCalloutsPresent(3); + + await ml.testExecution.logTestStep('continues to the create step'); + await ml.dataFrameAnalyticsCreation.continueToCreateStep(); + + await ml.testExecution.logTestStep('sets the create data view switch'); + await ml.dataFrameAnalyticsCreation.assertCreateIndexPatternSwitchExists(); + await ml.dataFrameAnalyticsCreation.setCreateIndexPatternSwitchState( + testData.createIndexPattern + ); + }); + + it('runs the analytics job and displays it correctly in the job list', async () => { + await ml.testExecution.logTestStep('creates and starts the analytics job'); + await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); + await ml.dataFrameAnalyticsCreation.assertStartJobCheckboxCheckState(true); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); + + await ml.testExecution.logTestStep('finishes analytics processing'); + await ml.dataFrameAnalytics.waitForAnalyticsCompletion(testData.jobId); + + await ml.testExecution.logTestStep('displays the analytics table'); + await ml.dataFrameAnalyticsCreation.navigateToJobManagementPage(); + await ml.dataFrameAnalytics.assertAnalyticsTableExists(); + + await ml.testExecution.logTestStep('displays the stats bar'); + await ml.dataFrameAnalytics.assertAnalyticsStatsBarExists(); + + await ml.testExecution.logTestStep('displays the created job in the analytics table'); + await ml.dataFrameAnalyticsTable.refreshAnalyticsTable(); + await ml.dataFrameAnalyticsTable.filterWithSearchString(testData.jobId, 1); + + await ml.testExecution.logTestStep( + 'displays details for the created job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: testData.jobDescription, + memoryStatus: testData.expected.row.memoryStatus, + sourceIndex: testData.expected.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowDetails( + testData.jobId, + testData.expected.rowDetails + ); + }); + + it('edits the analytics job and displays it correctly in the job list', async () => { + await ml.testExecution.logTestStep( + 'should open the edit form for the created job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.openEditFlyout(testData.jobId); + + await ml.testExecution.logTestStep('should input the description in the edit form'); + await ml.dataFrameAnalyticsEdit.assertJobDescriptionEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobDescriptionEdit(editedDescription); + + await ml.testExecution.logTestStep( + 'should input the model memory limit in the edit form' + ); + await ml.dataFrameAnalyticsEdit.assertJobMmlEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobMmlEdit('21mb'); + + await ml.testExecution.logTestStep('should submit the edit job form'); + await ml.dataFrameAnalyticsEdit.updateAnalyticsJob(); + + await ml.testExecution.logTestStep( + 'displays details for the edited job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: editedDescription, + memoryStatus: testData.expected.row.memoryStatus, + sourceIndex: testData.expected.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + + await ml.testExecution.logTestStep( + 'creates the destination index and writes results to it' + ); + await ml.api.assertIndicesExist(testData.destinationIndex); + await ml.api.assertIndicesNotEmpty(testData.destinationIndex); + + await ml.testExecution.logTestStep('displays the results view for created job'); + await ml.dataFrameAnalyticsTable.openResultsView(testData.jobId); + await ml.dataFrameAnalyticsResults.assertRegressionEvaluatePanelElementsExists(); + await ml.dataFrameAnalyticsResults.assertRegressionTablePanelExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); + await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); + + await ml.commonUI.resetAntiAliasing(); + }); + + it('displays the analytics job in the map view', async () => { + await ml.testExecution.logTestStep('should open the map view for created job'); + await ml.navigation.navigateToDataFrameAnalytics(); + await ml.dataFrameAnalyticsTable.openMapView(testData.jobId); + await ml.dataFrameAnalyticsMap.assertMapElementsExists(); + await ml.dataFrameAnalyticsMap.assertJobMapTitle(testData.jobId); + }); + }); + } + }); +} diff --git a/x-pack/test/functional/apps/ml/index.ts b/x-pack/test/functional/apps/ml/index.ts index eeae200f35ba7..c58b20e1c374b 100644 --- a/x-pack/test/functional/apps/ml/index.ts +++ b/x-pack/test/functional/apps/ml/index.ts @@ -25,6 +25,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await ml.securityCommon.cleanMlRoles(); await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote'); + await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote_small'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/ecommerce'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/categorization_small'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/event_rate_nanos'); diff --git a/x-pack/test/functional/es_archives/ml/farequote_small/data.json.gz b/x-pack/test/functional/es_archives/ml/farequote_small/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..7c82fda817373e596e4e847ebfa642bba10ec5a3 GIT binary patch literal 63274 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

D1(>;(UVd#+hH2St0xi!<>AR;-L>+?EzSb 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�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 BA-w { + async createSavedSearchIfNeeded(savedSearch: any, indexPatternTitle: string): Promise { const title = savedSearch.requestBody.attributes.title; const savedSearchId = await this.getSavedSearchId(title); if (savedSearchId !== undefined) { @@ -195,7 +195,7 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider } else { const body = await this.updateSavedSearchRequestBody( savedSearch.requestBody, - savedSearch.indexPatternTitle + indexPatternTitle ); return await this.createSavedSearch(title, body); } @@ -226,8 +226,8 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider return updatedBody; }, - async createSavedSearchFarequoteFilterIfNeeded() { - await this.createSavedSearchIfNeeded(savedSearches.farequoteFilter); + async createSavedSearchFarequoteFilterIfNeeded(indexPatternTitle: string = 'ft_farequote') { + await this.createSavedSearchIfNeeded(savedSearches.farequoteFilter, indexPatternTitle); }, async createMLTestDashboardIfNeeded() { @@ -249,20 +249,30 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider } }, - async createSavedSearchFarequoteLuceneIfNeeded() { - await this.createSavedSearchIfNeeded(savedSearches.farequoteLucene); + async createSavedSearchFarequoteLuceneIfNeeded(indexPatternTitle: string = 'ft_farequote') { + await this.createSavedSearchIfNeeded(savedSearches.farequoteLucene, indexPatternTitle); }, - async createSavedSearchFarequoteKueryIfNeeded() { - await this.createSavedSearchIfNeeded(savedSearches.farequoteKuery); + async createSavedSearchFarequoteKueryIfNeeded(indexPatternTitle: string = 'ft_farequote') { + await this.createSavedSearchIfNeeded(savedSearches.farequoteKuery, indexPatternTitle); }, - async createSavedSearchFarequoteFilterAndLuceneIfNeeded() { - await this.createSavedSearchIfNeeded(savedSearches.farequoteFilterAndLucene); + async createSavedSearchFarequoteFilterAndLuceneIfNeeded( + indexPatternTitle: string = 'ft_farequote' + ) { + await this.createSavedSearchIfNeeded( + savedSearches.farequoteFilterAndLucene, + indexPatternTitle + ); }, - async createSavedSearchFarequoteFilterAndKueryIfNeeded() { - await this.createSavedSearchIfNeeded(savedSearches.farequoteFilterAndKuery); + async createSavedSearchFarequoteFilterAndKueryIfNeeded( + indexPatternTitle: string = 'ft_farequote' + ) { + await this.createSavedSearchIfNeeded( + savedSearches.farequoteFilterAndKuery, + indexPatternTitle + ); }, async deleteSavedObjectById(id: string, objectType: SavedObjectType, force: boolean = false) { diff --git a/x-pack/test/functional/services/ml/test_resources_data.ts b/x-pack/test/functional/services/ml/test_resources_data.ts index 7502968bd2bb4..aeacc51cecbc9 100644 --- a/x-pack/test/functional/services/ml/test_resources_data.ts +++ b/x-pack/test/functional/services/ml/test_resources_data.ts @@ -7,7 +7,6 @@ export const savedSearches = { farequoteFilter: { - indexPatternTitle: 'ft_farequote', requestBody: { attributes: { title: 'ft_farequote_filter', @@ -66,7 +65,6 @@ export const savedSearches = { }, }, farequoteLucene: { - indexPatternTitle: 'ft_farequote', requestBody: { attributes: { title: 'ft_farequote_lucene', @@ -98,7 +96,6 @@ export const savedSearches = { }, }, farequoteKuery: { - indexPatternTitle: 'ft_farequote', requestBody: { attributes: { title: 'ft_farequote_kuery', @@ -130,7 +127,6 @@ export const savedSearches = { }, }, farequoteFilterAndLucene: { - indexPatternTitle: 'ft_farequote', requestBody: { attributes: { title: 'ft_farequote_filter_and_lucene', @@ -189,7 +185,6 @@ export const savedSearches = { }, }, farequoteFilterAndKuery: { - indexPatternTitle: 'ft_farequote', requestBody: { attributes: { title: 'ft_farequote_filter_and_kuery', From eafa4d998d0a35728d28c197a8a9fd2e4ef60e26 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 15 Feb 2022 13:17:12 -0700 Subject: [PATCH 29/39] [ML] Data Frame Analytics audit messages api test (#125325) * wip: add beginning of messages api test * adds dfa audit messages api test * use retry instead of running job --- .../ml_api_service/data_frame_analytics.ts | 3 +- .../apis/ml/data_frame_analytics/get.ts | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts index 8aba633970a78..42a18d80f9581 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts @@ -16,6 +16,7 @@ import { } from '../../data_frame_analytics/common'; import { DeepPartial } from '../../../../common/types/common'; import { NewJobCapsResponse } from '../../../../common/types/fields'; +import { JobMessage } from '../../../../common/types/audit_message'; import { DeleteDataFrameAnalyticsWithIndexStatus, AnalyticsMapReturnType, @@ -161,7 +162,7 @@ export const dataFrameAnalytics = { }); }, getAnalyticsAuditMessages(analyticsId: string) { - return http({ + return http({ path: `${basePath()}/data_frame/analytics/${analyticsId}/messages`, method: 'GET', }); diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/get.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/get.ts index f8c7009e39db6..69f40e46bc7e8 100644 --- a/x-pack/test/api_integration/apis/ml/data_frame_analytics/get.ts +++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/get.ts @@ -16,6 +16,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const supertest = getService('supertestWithoutAuth'); const ml = getService('ml'); + const retry = getService('retry'); const jobId = `bm_${Date.now()}`; @@ -273,5 +274,38 @@ export default ({ getService }: FtrProviderContext) => { expect(body).to.have.keys('elements', 'details', 'error'); }); }); + + describe('GetDataFrameAnalyticsMessages', () => { + it('should fetch single analytics job messages by id', async () => { + await retry.tryForTime(5000, async () => { + const { body } = await supertest + .get(`/api/ml/data_frame/analytics/${jobId}_1/messages`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + expect(body.length).to.eql(1); + expect(body[0].job_id).to.eql(`${jobId}_1`); + expect(body[0]).to.have.keys( + 'job_id', + 'message', + 'level', + 'timestamp', + 'node_name', + 'job_type' + ); + }); + }); + + it('should not allow to retrieve job messages without required permissions', async () => { + const { body } = await supertest + .get(`/api/ml/data_frame/analytics/${jobId}_1/messages`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .expect(403); + expect(body.error).to.eql('Forbidden'); + expect(body.message).to.eql('Forbidden'); + }); + }); }); }; From c6b356c437655060925febc5f1ee8ab5857e2161 Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" Date: Tue, 15 Feb 2022 14:48:40 -0600 Subject: [PATCH 30/39] [Security Solution] unskip tests (#125675) --- .../security_solution_endpoint_api_int/apis/metadata.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index a4c83b649af65..e6fd28d279fe7 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -38,8 +38,7 @@ export default function ({ getService }: FtrProviderContext) { describe('test metadata apis', () => { describe('list endpoints GET route', () => { - // FLAKY: https://github.com/elastic/kibana/issues/123253 - describe.skip('with .metrics-endpoint.metadata_united_default index', () => { + describe('with .metrics-endpoint.metadata_united_default index', () => { const numberOfHostsInFixture = 2; before(async () => { @@ -65,11 +64,11 @@ export default function ({ getService }: FtrProviderContext) { ]); // wait for latest metadata transform to run - await new Promise((r) => setTimeout(r, 30000)); + await new Promise((r) => setTimeout(r, 60000)); await startTransform(getService, METADATA_UNITED_TRANSFORM); // wait for united metadata transform to run - await new Promise((r) => setTimeout(r, 15000)); + await new Promise((r) => setTimeout(r, 30000)); }); after(async () => { From d946361eaf9884d9daaff88567baf50cea1ac9ae Mon Sep 17 00:00:00 2001 From: Jan Monschke Date: Tue, 15 Feb 2022 22:55:28 +0100 Subject: [PATCH 31/39] fix: add rule type to global fields for the alert flyout (#125676) --- .../components/event_details/__mocks__/index.ts | 6 ++++++ .../event_details/alert_summary_view.test.tsx | 12 ++++++++++++ .../event_details/get_alert_summary_rows.tsx | 1 + .../common/components/event_details/translations.ts | 4 ++++ 4 files changed, 23 insertions(+) diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts index f4b9a604f005d..a6e1a9875bd62 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts @@ -35,6 +35,12 @@ export const mockAlertDetailsData = [ originalValue: 'b9850845-c000-4ddd-bd51-9978a07b7e7d', }, { category: 'agent', field: 'agent.version', values: ['7.10.0'], originalValue: '7.10.0' }, + { + category: 'agent', + field: 'agent.status', + values: ['inactive'], + originalValue: ['inactive'], + }, { category: 'winlog', field: 'winlog.computer_name', diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx index fff723cd31cf4..24b907e6bd938 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx @@ -61,6 +61,18 @@ describe('AlertSummaryView', () => { expect(getAllByTestId('hover-actions-filter-for').length).toBeGreaterThan(0); }); + test('Renders the correct global fields', () => { + const { getByText } = render( + + + + ); + + ['host.name', 'user.name', 'Rule type', 'query'].forEach((fieldId) => { + expect(getByText(fieldId)); + }); + }); + test('it does NOT render the action cell for the active timeline', () => { const { queryAllByTestId } = render( diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx index 35f6b71b1dacf..af93393e5b8a4 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx @@ -37,6 +37,7 @@ const alwaysDisplayedFields: EventSummaryField[] = [ { id: 'host.name' }, { id: 'agent.id', overrideField: AGENT_STATUS_FIELD_NAME, label: i18n.AGENT_STATUS }, { id: 'user.name' }, + { id: ALERT_RULE_TYPE, label: i18n.RULE_TYPE }, ]; /** diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts index f042a5afc1ec4..8fefec910a3ce 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts @@ -84,6 +84,10 @@ export const AGENT_STATUS = i18n.translate('xpack.securitySolution.detections.al defaultMessage: 'Agent status', }); +export const RULE_TYPE = i18n.translate('xpack.securitySolution.detections.alerts.ruleType', { + defaultMessage: 'Rule type', +}); + export const MULTI_FIELD_TOOLTIP = i18n.translate( 'xpack.securitySolution.eventDetails.multiFieldTooltipContent', { From 7b4c34e41819294399421c1d047241cc1e9e5ee2 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 15 Feb 2022 16:40:09 -0600 Subject: [PATCH 32/39] Revert "[build] Include x-pack example plugins when using example-plugins flag (#120697)" (#125729) This reverts commit 3a4c6030b901241eb8e9fbef337c5d36f6fd2fb5. --- .../tasks/build_kibana_example_plugins.ts | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/dev/build/tasks/build_kibana_example_plugins.ts b/src/dev/build/tasks/build_kibana_example_plugins.ts index 93ebf41d259e7..7eb696ffdd3b2 100644 --- a/src/dev/build/tasks/build_kibana_example_plugins.ts +++ b/src/dev/build/tasks/build_kibana_example_plugins.ts @@ -13,23 +13,17 @@ import { exec, mkdirp, copyAll, Task } from '../lib'; export const BuildKibanaExamplePlugins: Task = { description: 'Building distributable versions of Kibana example plugins', - async run(config, log) { + async run(config, log, build) { + const examplesDir = Path.resolve(REPO_ROOT, 'examples'); const args = [ - Path.resolve(REPO_ROOT, 'scripts/plugin_helpers'), + '../../scripts/plugin_helpers', 'build', `--kibana-version=${config.getBuildVersion()}`, ]; - const getExampleFolders = (dir: string) => { - return Fs.readdirSync(dir, { withFileTypes: true }) - .filter((f) => f.isDirectory()) - .map((f) => Path.resolve(dir, f.name)); - }; - - const folders = [ - ...getExampleFolders(Path.resolve(REPO_ROOT, 'examples')), - ...getExampleFolders(Path.resolve(REPO_ROOT, 'x-pack/examples')), - ]; + const folders = Fs.readdirSync(examplesDir, { withFileTypes: true }) + .filter((f) => f.isDirectory()) + .map((f) => Path.resolve(REPO_ROOT, 'examples', f.name)); for (const examplePlugin of folders) { try { @@ -46,8 +40,8 @@ export const BuildKibanaExamplePlugins: Task = { const pluginsDir = config.resolveFromTarget('example_plugins'); await mkdirp(pluginsDir); - await copyAll(REPO_ROOT, pluginsDir, { - select: ['examples/*/build/*.zip', 'x-pack/examples/*/build/*.zip'], + await copyAll(examplesDir, pluginsDir, { + select: ['*/build/*.zip'], }); }, }; From b11a829d0d068030810a71567dab2169d04432e6 Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Tue, 15 Feb 2022 17:46:00 -0500 Subject: [PATCH 33/39] [App Search] New flyout to start a crawl with custom settings (#124999) --- .../crawl_custom_settings_flyout.test.tsx | 152 ++++++ .../crawl_custom_settings_flyout.tsx | 108 +++++ ...settings_flyout_crawl_depth_panel.test.tsx | 45 ++ ...stom_settings_flyout_crawl_depth_panel.tsx | 64 +++ ...tom_settings_flyout_domains_panel.test.tsx | 76 +++ ...l_custom_settings_flyout_domains_panel.tsx | 84 ++++ ...crawl_custom_settings_flyout_logic.test.ts | 448 ++++++++++++++++++ .../crawl_custom_settings_flyout_logic.ts | 225 +++++++++ ...m_settings_flyout_seed_urls_panel.test.tsx | 165 +++++++ ...custom_settings_flyout_seed_urls_panel.tsx | 205 ++++++++ .../url_combo_box.scss | 5 + .../url_combo_box.test.tsx | 126 +++++ .../url_combo_box.tsx | 84 ++++ .../url_combo_box_logic.test.ts | 37 ++ .../url_combo_box_logic.ts | 32 ++ .../simplified_selectable.test.tsx | 5 + .../simplified_selectable.tsx | 9 +- .../start_crawl_context_menu.test.tsx | 12 +- .../start_crawl_context_menu.tsx | 17 +- .../components/crawler/crawler_logic.ts | 10 +- .../components/crawler/crawler_overview.tsx | 2 + .../crawler/crawler_single_domain.tsx | 2 + .../app_search/components/crawler/types.ts | 14 + .../components/crawler/utils.test.ts | 19 + .../app_search/components/crawler/utils.ts | 11 + .../server/routes/app_search/crawler.test.ts | 69 ++- .../server/routes/app_search/crawler.ts | 18 + 27 files changed, 2038 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_crawl_depth_panel.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_crawl_depth_panel.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_domains_panel.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_domains_panel.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_seed_urls_panel.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_seed_urls_panel.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box_logic.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout.test.tsx new file mode 100644 index 0000000000000..5ca0ea23c15b0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout.test.tsx @@ -0,0 +1,152 @@ +/* + * 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 { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiButton, EuiButtonEmpty, EuiFlyout, EuiFlyoutFooter } from '@elastic/eui'; + +import { Loading } from '../../../../../shared/loading'; +import { rerender } from '../../../../../test_helpers'; + +import { CrawlCustomSettingsFlyout } from './crawl_custom_settings_flyout'; +import { CrawlCustomSettingsFlyoutCrawlDepthPanel } from './crawl_custom_settings_flyout_crawl_depth_panel'; +import { CrawlCustomSettingsFlyoutDomainsPanel } from './crawl_custom_settings_flyout_domains_panel'; +import { CrawlCustomSettingsFlyoutSeedUrlsPanel } from './crawl_custom_settings_flyout_seed_urls_panel'; + +const MOCK_VALUES = { + // CrawlCustomSettingsFlyoutLogic + isDataLoading: false, + isFormSubmitting: false, + isFlyoutVisible: true, + selectedDomainUrls: ['https://www.elastic.co'], +}; + +const MOCK_ACTIONS = { + // CrawlCustomSettingsFlyoutLogic + hideFlyout: jest.fn(), + onSelectDomainUrls: jest.fn(), + startCustomCrawl: jest.fn(), +}; + +describe('CrawlCustomSettingsFlyout', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(MOCK_VALUES); + setMockActions(MOCK_ACTIONS); + + wrapper = shallow(); + }); + + it('is empty when the flyout is hidden', () => { + setMockValues({ + ...MOCK_VALUES, + isFlyoutVisible: false, + }); + + rerender(wrapper); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders as a modal when visible', () => { + expect(wrapper.is(EuiFlyout)).toBe(true); + }); + + it('can be closed', () => { + expect(wrapper.prop('onClose')).toEqual(MOCK_ACTIONS.hideFlyout); + expect(wrapper.find(EuiFlyoutFooter).find(EuiButtonEmpty).prop('onClick')).toEqual( + MOCK_ACTIONS.hideFlyout + ); + }); + + it('lets the user customize their crawl', () => { + expect(wrapper.find(Loading)).toHaveLength(0); + for (const component of [ + CrawlCustomSettingsFlyoutCrawlDepthPanel, + CrawlCustomSettingsFlyoutDomainsPanel, + CrawlCustomSettingsFlyoutSeedUrlsPanel, + ]) { + expect(wrapper.find(component)).toHaveLength(1); + } + }); + + it('shows a loading state', () => { + setMockValues({ + ...MOCK_VALUES, + isDataLoading: true, + }); + + rerender(wrapper); + + expect(wrapper.find(Loading)).toHaveLength(1); + for (const component of [ + CrawlCustomSettingsFlyoutCrawlDepthPanel, + CrawlCustomSettingsFlyoutDomainsPanel, + CrawlCustomSettingsFlyoutSeedUrlsPanel, + ]) { + expect(wrapper.find(component)).toHaveLength(0); + } + }); + + describe('submit button', () => { + it('is enabled by default', () => { + setMockValues({ + ...MOCK_VALUES, + selectedDomainUrls: [], + }); + + rerender(wrapper); + + expect(wrapper.find(EuiFlyoutFooter).find(EuiButton).prop('disabled')).toEqual(true); + }); + + it('is disabled when no domains are selected', () => { + setMockValues({ + ...MOCK_VALUES, + selectedDomainUrls: [], + }); + + rerender(wrapper); + + expect(wrapper.find(EuiFlyoutFooter).find(EuiButton).prop('disabled')).toEqual(true); + }); + + it('is disabled when data is loading', () => { + setMockValues({ + ...MOCK_VALUES, + isDataLoading: true, + }); + + rerender(wrapper); + + expect(wrapper.find(EuiFlyoutFooter).find(EuiButton).prop('disabled')).toEqual(true); + }); + + it('shows a loading state when the user makes a request', () => { + setMockValues({ + ...MOCK_VALUES, + isFormSubmitting: true, + }); + + rerender(wrapper); + + expect(wrapper.find(EuiFlyoutFooter).find(EuiButton).prop('isLoading')).toEqual(true); + }); + + it('starts a crawl and hides the modal', () => { + wrapper.find(EuiFlyoutFooter).find(EuiButton).simulate('click'); + + expect(MOCK_ACTIONS.startCustomCrawl).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout.tsx new file mode 100644 index 0000000000000..a310c76072284 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout.tsx @@ -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 React from 'react'; + +import { useValues, useActions } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { CANCEL_BUTTON_LABEL } from '../../../../../shared/constants'; +import { Loading } from '../../../../../shared/loading'; + +import { CrawlCustomSettingsFlyoutCrawlDepthPanel } from './crawl_custom_settings_flyout_crawl_depth_panel'; +import { CrawlCustomSettingsFlyoutDomainsPanel } from './crawl_custom_settings_flyout_domains_panel'; +import { CrawlCustomSettingsFlyoutLogic } from './crawl_custom_settings_flyout_logic'; +import { CrawlCustomSettingsFlyoutSeedUrlsPanel } from './crawl_custom_settings_flyout_seed_urls_panel'; + +export const CrawlCustomSettingsFlyout: React.FC = () => { + const { isDataLoading, isFormSubmitting, isFlyoutVisible, selectedDomainUrls } = useValues( + CrawlCustomSettingsFlyoutLogic + ); + const { hideFlyout, startCustomCrawl } = useActions(CrawlCustomSettingsFlyoutLogic); + + if (!isFlyoutVisible) { + return null; + } + + return ( + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlCustomSettingsFlyout.flyoutHeadTitle', + { + defaultMessage: 'Custom crawl configuration', + } + )} +

+ + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlCustomSettingsFlyout.flyoutHeaderDescription', + { + defaultMessage: 'Set up a one-time crawl with custom settings.', + } + )} +

+
+ + + {isDataLoading ? ( + + ) : ( + <> + + + + + + + )} + + + + + {CANCEL_BUTTON_LABEL} + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlCustomSettingsFlyout.startCrawlButtonLabel', + { + defaultMessage: 'Apply and crawl now', + } + )} + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_crawl_depth_panel.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_crawl_depth_panel.test.tsx new file mode 100644 index 0000000000000..24932de7cfb36 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_crawl_depth_panel.test.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 { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFieldNumber } from '@elastic/eui'; + +import { CrawlCustomSettingsFlyoutCrawlDepthPanel } from './crawl_custom_settings_flyout_crawl_depth_panel'; + +const MOCK_VALUES = { + // CrawlCustomSettingsFlyoutLogic + maxCrawlDepth: 5, +}; + +const MOCK_ACTIONS = { + // CrawlCustomSettingsFlyoutLogic + onSelectMaxCrawlDepth: jest.fn(), +}; + +describe('CrawlCustomSettingsFlyoutCrawlDepthPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(MOCK_VALUES); + setMockActions(MOCK_ACTIONS); + }); + + it('allows the user to set max crawl depth', () => { + const wrapper = shallow(); + const crawlDepthField = wrapper.find(EuiFieldNumber); + + expect(crawlDepthField.prop('value')).toEqual(5); + + crawlDepthField.simulate('change', { target: { value: '10' } }); + + expect(MOCK_ACTIONS.onSelectMaxCrawlDepth).toHaveBeenCalledWith(10); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_crawl_depth_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_crawl_depth_panel.tsx new file mode 100644 index 0000000000000..f52f0dacf4f2f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_crawl_depth_panel.tsx @@ -0,0 +1,64 @@ +/* + * 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, { ChangeEvent } from 'react'; + +import { useValues, useActions } from 'kea'; + +import { + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPanel, + EuiText, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { CrawlCustomSettingsFlyoutLogic } from './crawl_custom_settings_flyout_logic'; + +export const CrawlCustomSettingsFlyoutCrawlDepthPanel: React.FC = () => { + const { maxCrawlDepth } = useValues(CrawlCustomSettingsFlyoutLogic); + const { onSelectMaxCrawlDepth } = useActions(CrawlCustomSettingsFlyoutLogic); + + return ( + + + + + ) => + onSelectMaxCrawlDepth(parseInt(e.target.value, 10)) + } + /> + + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlCustomSettingsFlyout.maxCrawlDepthFieldDescription', + { + defaultMessage: + 'Set a max crawl depth to specify how many pages deep the crawler should traverse. Set the value to one (1) to limit the crawl to only the entry points.', + } + )} + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_domains_panel.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_domains_panel.test.tsx new file mode 100644 index 0000000000000..e0433a70a3df5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_domains_panel.test.tsx @@ -0,0 +1,76 @@ +/* + * 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 { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiAccordion, EuiNotificationBadge } from '@elastic/eui'; + +import { rerender } from '../../../../../test_helpers'; +import { SimplifiedSelectable } from '../crawl_select_domains_modal/simplified_selectable'; + +import { CrawlCustomSettingsFlyoutDomainsPanel } from './crawl_custom_settings_flyout_domains_panel'; + +const MOCK_VALUES = { + // CrawlCustomSettingsFlyoutLogic + domainUrls: ['https://www.elastic.co', 'https://www.swiftype.com'], + selectedDomainUrls: ['https://www.elastic.co'], +}; + +const MOCK_ACTIONS = { + // CrawlCustomSettingsFlyoutLogic + onSelectDomainUrls: jest.fn(), +}; + +const getAccordionBadge = (wrapper: ShallowWrapper) => { + const accordionWrapper = wrapper.find(EuiAccordion); + const extraActionWrapper = shallow(
{accordionWrapper.prop('extraAction')}
); + return extraActionWrapper.find(EuiNotificationBadge); +}; + +describe('CrawlCustomSettingsFlyoutDomainsPanel', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(MOCK_VALUES); + setMockActions(MOCK_ACTIONS); + + wrapper = shallow(); + }); + + it('allows the user to select domains', () => { + const domainAccordionWrapper = wrapper.find(EuiAccordion); + + expect(domainAccordionWrapper.find(SimplifiedSelectable).props()).toEqual({ + options: ['https://www.elastic.co', 'https://www.swiftype.com'], + selectedOptions: ['https://www.elastic.co'], + onChange: MOCK_ACTIONS.onSelectDomainUrls, + }); + }); + + it('indicates how many domains are selected', () => { + let badge = getAccordionBadge(wrapper); + + expect(badge.render().text()).toContain('1'); + expect(badge.prop('color')).toEqual('accent'); + + setMockValues({ + ...MOCK_VALUES, + selectedDomainUrls: [], + }); + + rerender(wrapper); + badge = getAccordionBadge(wrapper); + + expect(badge.render().text()).toContain('0'); + expect(badge.prop('color')).toEqual('subdued'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_domains_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_domains_panel.tsx new file mode 100644 index 0000000000000..705e75fe36e7d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_domains_panel.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues, useActions } from 'kea'; + +import { + EuiAccordion, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiNotificationBadge, + EuiPanel, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { SimplifiedSelectable } from '../crawl_select_domains_modal/simplified_selectable'; + +import { CrawlCustomSettingsFlyoutLogic } from './crawl_custom_settings_flyout_logic'; + +export const CrawlCustomSettingsFlyoutDomainsPanel: React.FC = () => { + const { domainUrls, selectedDomainUrls } = useValues(CrawlCustomSettingsFlyoutLogic); + const { onSelectDomainUrls } = useActions(CrawlCustomSettingsFlyoutLogic); + + return ( + + + + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlCustomSettingsFlyout.domainsAccordionButtonLabel', + { + defaultMessage: 'Add domains to your crawl', + } + )} +

+
+
+ + } + extraAction={ + + 0 ? 'accent' : 'subdued'} + > + {selectedDomainUrls.length} + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlCustomSettingsFlyout.selectedDescriptor', + { + defaultMessage: 'selected', + } + )} + + + } + > + +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.test.ts new file mode 100644 index 0000000000000..ddfc23b5aa628 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.test.ts @@ -0,0 +1,448 @@ +/* + * 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 { LogicMounter, mockHttpValues } from '../../../../../__mocks__/kea_logic'; +import '../../../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test-jest-helpers'; + +import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; +import { CrawlerLogic } from '../../crawler_logic'; +import { DomainConfig } from '../../types'; + +import { CrawlCustomSettingsFlyoutLogic } from './crawl_custom_settings_flyout_logic'; + +describe('CrawlCustomSettingsFlyoutLogic', () => { + const { mount } = new LogicMounter(CrawlCustomSettingsFlyoutLogic); + const { http } = mockHttpValues; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(CrawlCustomSettingsFlyoutLogic.values).toEqual({ + customEntryPointUrls: [], + customSitemapUrls: [], + domainConfigMap: {}, + domainConfigs: [], + domainUrls: [], + entryPointUrls: [], + includeSitemapsInRobotsTxt: true, + isDataLoading: true, + isFlyoutVisible: false, + isFormSubmitting: false, + maxCrawlDepth: 2, + selectedDomainUrls: [], + selectedEntryPointUrls: [], + selectedSitemapUrls: [], + sitemapUrls: [], + }); + }); + + describe('actions', () => { + describe('fetchDomainConfigData', () => { + it('updates logic with data that has been converted from server to client', async () => { + jest.spyOn(CrawlCustomSettingsFlyoutLogic.actions, 'onRecieveDomainConfigData'); + http.get.mockReturnValueOnce( + Promise.resolve({ + results: [ + { + id: '1234', + name: 'https://www.elastic.co', + seed_urls: [], + sitemap_urls: [], + }, + ], + }) + ); + + CrawlCustomSettingsFlyoutLogic.actions.fetchDomainConfigData(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/internal/app_search/engines/some-engine/crawler/domain_configs' + ); + expect( + CrawlCustomSettingsFlyoutLogic.actions.onRecieveDomainConfigData + ).toHaveBeenCalledWith([ + { + id: '1234', + name: 'https://www.elastic.co', + seedUrls: [], + sitemapUrls: [], + }, + ]); + }); + + itShowsServerErrorAsFlashMessage(http.get, () => { + CrawlCustomSettingsFlyoutLogic.actions.fetchDomainConfigData(); + }); + }); + + describe('hideFlyout', () => { + it('hides the modal', () => { + CrawlCustomSettingsFlyoutLogic.actions.hideFlyout(); + + expect(CrawlCustomSettingsFlyoutLogic.values.isFlyoutVisible).toBe(false); + }); + }); + + describe('onRecieveDomainConfigData', () => { + it('saves the data', () => { + mount({ + domainConfigs: [], + }); + + CrawlCustomSettingsFlyoutLogic.actions.onRecieveDomainConfigData([ + { + name: 'https://www.elastic.co', + }, + ] as DomainConfig[]); + + expect(CrawlCustomSettingsFlyoutLogic.values.domainConfigs).toEqual([ + { + name: 'https://www.elastic.co', + }, + ]); + }); + }); + + describe('onSelectCustomSitemapUrls', () => { + it('saves the urls', () => { + mount({ + customSitemapUrls: [], + }); + + CrawlCustomSettingsFlyoutLogic.actions.onSelectCustomSitemapUrls([ + 'https://www.elastic.co/custom-sitemap1.xml', + 'https://swiftype.com/custom-sitemap2.xml', + ]); + + expect(CrawlCustomSettingsFlyoutLogic.values.customSitemapUrls).toEqual([ + 'https://www.elastic.co/custom-sitemap1.xml', + 'https://swiftype.com/custom-sitemap2.xml', + ]); + }); + }); + + describe('onSelectCustomEntryPointUrls', () => { + it('saves the urls', () => { + mount({ + customEntryPointUrls: [], + }); + + CrawlCustomSettingsFlyoutLogic.actions.onSelectCustomEntryPointUrls([ + 'https://www.elastic.co/custom-entry-point', + 'https://swiftype.com/custom-entry-point', + ]); + + expect(CrawlCustomSettingsFlyoutLogic.values.customEntryPointUrls).toEqual([ + 'https://www.elastic.co/custom-entry-point', + 'https://swiftype.com/custom-entry-point', + ]); + }); + }); + + describe('onSelectDomainUrls', () => { + it('saves the urls', () => { + mount({ + selectedDomainUrls: [], + }); + + CrawlCustomSettingsFlyoutLogic.actions.onSelectDomainUrls([ + 'https://www.elastic.co', + 'https://swiftype.com', + ]); + + expect(CrawlCustomSettingsFlyoutLogic.values.selectedDomainUrls).toEqual([ + 'https://www.elastic.co', + 'https://swiftype.com', + ]); + }); + + it('filters selected sitemap urls by selected domains', () => { + mount({ + selectedDomainUrls: ['https://www.elastic.co', 'https://swiftype.com'], + selectedSitemapUrls: [ + 'https://www.elastic.co/sitemap1.xml', + 'https://swiftype.com/sitemap2.xml', + ], + }); + + CrawlCustomSettingsFlyoutLogic.actions.onSelectDomainUrls(['https://swiftype.com']); + + expect(CrawlCustomSettingsFlyoutLogic.values.selectedSitemapUrls).toEqual([ + 'https://swiftype.com/sitemap2.xml', + ]); + }); + + it('filters selected entry point urls by selected domains', () => { + mount({ + selectedDomainUrls: ['https://www.elastic.co', 'https://swiftype.com'], + selectedEntryPointUrls: [ + 'https://www.elastic.co/guide', + 'https://swiftype.com/documentation', + ], + }); + + CrawlCustomSettingsFlyoutLogic.actions.onSelectDomainUrls(['https://swiftype.com']); + + expect(CrawlCustomSettingsFlyoutLogic.values.selectedEntryPointUrls).toEqual([ + 'https://swiftype.com/documentation', + ]); + }); + }); + + describe('onSelectEntryPointUrls', () => { + it('saves the urls', () => { + mount({ + selectedEntryPointUrls: [], + }); + + CrawlCustomSettingsFlyoutLogic.actions.onSelectEntryPointUrls([ + 'https://www.elastic.co/guide', + 'https://swiftype.com/documentation', + ]); + + expect(CrawlCustomSettingsFlyoutLogic.values.selectedEntryPointUrls).toEqual([ + 'https://www.elastic.co/guide', + 'https://swiftype.com/documentation', + ]); + }); + }); + + describe('onSelectMaxCrawlDepth', () => { + it('saves the crawl depth', () => { + mount({ + maxCrawlDepth: 5, + }); + + CrawlCustomSettingsFlyoutLogic.actions.onSelectMaxCrawlDepth(10); + + expect(CrawlCustomSettingsFlyoutLogic.values.maxCrawlDepth).toEqual(10); + }); + }); + + describe('onSelectSitemapUrls', () => { + it('saves the urls', () => { + mount({ + selectedSitemapUrls: [], + }); + + CrawlCustomSettingsFlyoutLogic.actions.onSelectSitemapUrls([ + 'https://www.elastic.co/sitemap1.xml', + 'https://swiftype.com/sitemap2.xml', + ]); + + expect(CrawlCustomSettingsFlyoutLogic.values.selectedSitemapUrls).toEqual([ + 'https://www.elastic.co/sitemap1.xml', + 'https://swiftype.com/sitemap2.xml', + ]); + }); + }); + + describe('showFlyout', () => { + it('shows the modal and resets the form', () => { + mount({ + customEntryPointUrls: [ + 'https://www.elastic.co/custom-entry-point', + 'https://swiftype.com/custom-entry-point', + ], + customSitemapUrls: [ + 'https://www.elastic.co/custom-sitemap1.xml', + 'https://swiftype.com/custom-sitemap2.xml', + ], + includeSitemapsInRobotsTxt: false, + isDataLoading: false, + isFlyoutVisible: false, + selectedDomainUrls: ['https://www.elastic.co', 'https://swiftype.com'], + selectedEntryPointUrls: [ + 'https://www.elastic.co/guide', + 'https://swiftype.com/documentation', + ], + selectedSitemapUrls: [ + 'https://www.elastic.co/sitemap1.xml', + 'https://swiftype.com/sitemap2.xml', + ], + }); + + CrawlCustomSettingsFlyoutLogic.actions.showFlyout(); + + expect(CrawlCustomSettingsFlyoutLogic.values).toEqual( + expect.objectContaining({ + customEntryPointUrls: [], + customSitemapUrls: [], + includeSitemapsInRobotsTxt: true, + isDataLoading: true, + isFlyoutVisible: true, + selectedDomainUrls: [], + selectedEntryPointUrls: [], + selectedSitemapUrls: [], + }) + ); + }); + + it('fetches the latest data', () => { + jest.spyOn(CrawlCustomSettingsFlyoutLogic.actions, 'fetchDomainConfigData'); + + CrawlCustomSettingsFlyoutLogic.actions.showFlyout(); + + expect(CrawlCustomSettingsFlyoutLogic.actions.fetchDomainConfigData).toHaveBeenCalled(); + }); + }); + + describe('startCustomCrawl', () => { + it('starts a custom crawl with the user set values', async () => { + mount({ + includeSitemapsInRobotsTxt: true, + maxCrawlDepth: 5, + selectedDomainUrls: ['https://www.elastic.co', 'https://swiftype.com'], + selectedEntryPointUrls: [ + 'https://www.elastic.co/guide', + 'https://swiftype.com/documentation', + ], + selectedSitemapUrls: [ + 'https://www.elastic.co/sitemap1.xml', + 'https://swiftype.com/sitemap2.xml', + ], + }); + jest.spyOn(CrawlerLogic.actions, 'startCrawl'); + + CrawlCustomSettingsFlyoutLogic.actions.startCustomCrawl(); + await nextTick(); + + expect(CrawlerLogic.actions.startCrawl).toHaveBeenCalledWith({ + domain_allowlist: ['https://www.elastic.co', 'https://swiftype.com'], + max_crawl_depth: 5, + seed_urls: ['https://www.elastic.co/guide', 'https://swiftype.com/documentation'], + sitemap_urls: [ + 'https://www.elastic.co/sitemap1.xml', + 'https://swiftype.com/sitemap2.xml', + ], + sitemap_discovery_disabled: false, + }); + }); + }); + + describe('toggleIncludeSitemapsInRobotsTxt', () => { + it('toggles the flag', () => { + mount({ + includeSitemapsInRobotsTxt: false, + }); + + CrawlCustomSettingsFlyoutLogic.actions.toggleIncludeSitemapsInRobotsTxt(); + + expect(CrawlCustomSettingsFlyoutLogic.values.includeSitemapsInRobotsTxt).toEqual(true); + + mount({ + includeSitemapsInRobotsTxt: true, + }); + + CrawlCustomSettingsFlyoutLogic.actions.toggleIncludeSitemapsInRobotsTxt(); + + expect(CrawlCustomSettingsFlyoutLogic.values.includeSitemapsInRobotsTxt).toEqual(false); + }); + }); + + describe('[CrawlerLogic.actionTypes.startCrawl]', () => { + it('enables loading state', () => { + mount({ + isFormSubmitting: false, + }); + + CrawlerLogic.actions.startCrawl(); + + expect(CrawlCustomSettingsFlyoutLogic.values.isFormSubmitting).toBe(true); + }); + }); + + describe('[CrawlerLogic.actionTypes.onStartCrawlRequestComplete]', () => { + it('disables loading state and hides the modal', () => { + mount({ + isFormSubmitting: true, + isFlyoutVisible: true, + }); + + CrawlerLogic.actions.onStartCrawlRequestComplete(); + + expect(CrawlCustomSettingsFlyoutLogic.values.isFormSubmitting).toBe(false); + expect(CrawlCustomSettingsFlyoutLogic.values.isFlyoutVisible).toBe(false); + }); + }); + }); + + describe('selectors', () => { + beforeEach(() => { + mount({ + domainConfigs: [ + { + name: 'https://www.elastic.co', + sitemapUrls: [ + 'https://www.elastic.co/sitemap1.xml', + 'https://www.elastic.co/sitemap2.xml', + ], + seedUrls: ['https://www.elastic.co/', 'https://www.elastic.co/guide'], + }, + { + name: 'https://swiftype.com', + sitemapUrls: ['https://swiftype.com/sitemap1.xml', 'https://swiftype.com/sitemap2.xml'], + seedUrls: ['https://swiftype.com/', 'https://swiftype.com/documentation'], + }, + ], + selectedDomainUrls: ['https://swiftype.com'], + }); + }); + + describe('domainUrls', () => { + it('contains all the domain urls from the domain config', () => { + expect(CrawlCustomSettingsFlyoutLogic.values.domainUrls).toEqual([ + 'https://www.elastic.co', + 'https://swiftype.com', + ]); + }); + }); + + describe('domainConfigMap', () => { + it('contains all the domain urls from the domain config', () => { + expect(CrawlCustomSettingsFlyoutLogic.values.domainConfigMap).toEqual({ + 'https://www.elastic.co': { + name: 'https://www.elastic.co', + sitemapUrls: [ + 'https://www.elastic.co/sitemap1.xml', + 'https://www.elastic.co/sitemap2.xml', + ], + seedUrls: ['https://www.elastic.co/', 'https://www.elastic.co/guide'], + }, + 'https://swiftype.com': { + name: 'https://swiftype.com', + sitemapUrls: ['https://swiftype.com/sitemap1.xml', 'https://swiftype.com/sitemap2.xml'], + seedUrls: ['https://swiftype.com/', 'https://swiftype.com/documentation'], + }, + }); + }); + }); + + describe('entryPointUrls', () => { + it('contains all the sitemap urls from selected domains', () => { + expect(CrawlCustomSettingsFlyoutLogic.values.entryPointUrls).toEqual([ + 'https://swiftype.com/', + 'https://swiftype.com/documentation', + ]); + }); + }); + + describe('sitemapUrls', () => { + it('contains all the sitemap urls from selected domains', () => { + expect(CrawlCustomSettingsFlyoutLogic.values.sitemapUrls).toEqual([ + 'https://swiftype.com/sitemap1.xml', + 'https://swiftype.com/sitemap2.xml', + ]); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.ts new file mode 100644 index 0000000000000..f22dcc7487af3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic.ts @@ -0,0 +1,225 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../shared/http'; +import { EngineLogic } from '../../../engine'; + +import { CrawlerLogic } from '../../crawler_logic'; +import { DomainConfig, DomainConfigFromServer } from '../../types'; +import { domainConfigServerToClient } from '../../utils'; +import { extractDomainAndEntryPointFromUrl } from '../add_domain/utils'; + +export interface CrawlCustomSettingsFlyoutLogicValues { + customEntryPointUrls: string[]; + customSitemapUrls: string[]; + domainUrls: string[]; + domainConfigs: DomainConfig[]; + domainConfigMap: { + [key: string]: DomainConfig; + }; + entryPointUrls: string[]; + includeSitemapsInRobotsTxt: boolean; + isDataLoading: boolean; + isFormSubmitting: boolean; + isFlyoutVisible: boolean; + maxCrawlDepth: number; + selectedDomainUrls: string[]; + selectedEntryPointUrls: string[]; + selectedSitemapUrls: string[]; + sitemapUrls: string[]; +} + +export interface CrawlCustomSettingsFlyoutLogicActions { + fetchDomainConfigData(): void; + hideFlyout(): void; + onRecieveDomainConfigData(domainConfigs: DomainConfig[]): { domainConfigs: DomainConfig[] }; + onSelectCustomEntryPointUrls(entryPointUrls: string[]): { entryPointUrls: string[] }; + onSelectCustomSitemapUrls(sitemapUrls: string[]): { sitemapUrls: string[] }; + onSelectDomainUrls(domainUrls: string[]): { domainUrls: string[] }; + onSelectEntryPointUrls(entryPointUrls: string[]): { entryPointUrls: string[] }; + onSelectMaxCrawlDepth(maxCrawlDepth: number): { maxCrawlDepth: number }; + onSelectSitemapUrls(sitemapUrls: string[]): { sitemapUrls: string[] }; + showFlyout(): void; + startCustomCrawl(): void; + toggleIncludeSitemapsInRobotsTxt(): void; +} + +const filterSeedUrlsByDomainUrls = (seedUrls: string[], domainUrls: string[]): string[] => { + const domainUrlMap = domainUrls.reduce( + (acc, domainUrl) => ({ ...acc, [domainUrl]: true }), + {} as { [key: string]: boolean } + ); + + return seedUrls.filter((seedUrl) => { + const { domain } = extractDomainAndEntryPointFromUrl(seedUrl); + return !!domainUrlMap[domain]; + }); +}; + +export const CrawlCustomSettingsFlyoutLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'crawler', 'crawl_custom_settings_flyout'], + actions: () => ({ + fetchDomainConfigData: true, + hideFlyout: true, + onRecieveDomainConfigData: (domainConfigs) => ({ domainConfigs }), + onSelectCustomEntryPointUrls: (entryPointUrls) => ({ entryPointUrls }), + onSelectCustomSitemapUrls: (sitemapUrls) => ({ sitemapUrls }), + onSelectDomainUrls: (domainUrls) => ({ domainUrls }), + onSelectEntryPointUrls: (entryPointUrls) => ({ entryPointUrls }), + onSelectMaxCrawlDepth: (maxCrawlDepth) => ({ maxCrawlDepth }), + onSelectSitemapUrls: (sitemapUrls) => ({ sitemapUrls }), + startCustomCrawl: true, + toggleIncludeSitemapsInRobotsTxt: true, + showFlyout: true, + }), + reducers: () => ({ + customEntryPointUrls: [ + [], + { + showFlyout: () => [], + onSelectCustomEntryPointUrls: (_, { entryPointUrls }) => entryPointUrls, + }, + ], + customSitemapUrls: [ + [], + { + showFlyout: () => [], + onSelectCustomSitemapUrls: (_, { sitemapUrls }) => sitemapUrls, + }, + ], + domainConfigs: [ + [], + { + onRecieveDomainConfigData: (_, { domainConfigs }) => domainConfigs, + }, + ], + includeSitemapsInRobotsTxt: [ + true, + { + showFlyout: () => true, + toggleIncludeSitemapsInRobotsTxt: (includeSitemapsInRobotsTxt) => + !includeSitemapsInRobotsTxt, + }, + ], + isDataLoading: [ + true, + { + showFlyout: () => true, + onRecieveDomainConfigData: () => false, + }, + ], + isFormSubmitting: [ + false, + { + [CrawlerLogic.actionTypes.startCrawl]: () => true, + [CrawlerLogic.actionTypes.onStartCrawlRequestComplete]: () => false, + }, + ], + isFlyoutVisible: [ + false, + { + showFlyout: () => true, + hideFlyout: () => false, + [CrawlerLogic.actionTypes.onStartCrawlRequestComplete]: () => false, + }, + ], + maxCrawlDepth: [ + 2, + { + showFlyout: () => 2, + onSelectMaxCrawlDepth: (_, { maxCrawlDepth }) => maxCrawlDepth, + }, + ], + selectedDomainUrls: [ + [], + { + showFlyout: () => [], + onSelectDomainUrls: (_, { domainUrls }) => domainUrls, + }, + ], + selectedEntryPointUrls: [ + [], + { + showFlyout: () => [], + onSelectEntryPointUrls: (_, { entryPointUrls }) => entryPointUrls, + onSelectDomainUrls: (entryPointUrls, { domainUrls }) => + filterSeedUrlsByDomainUrls(entryPointUrls, domainUrls), + }, + ], + selectedSitemapUrls: [ + [], + { + showFlyout: () => [], + onSelectSitemapUrls: (_, { sitemapUrls }) => sitemapUrls, + onSelectDomainUrls: (selectedSitemapUrls, { domainUrls }) => + filterSeedUrlsByDomainUrls(selectedSitemapUrls, domainUrls), + }, + ], + }), + selectors: () => ({ + domainUrls: [ + (selectors) => [selectors.domainConfigs], + (domainConfigs: DomainConfig[]) => domainConfigs.map((domainConfig) => domainConfig.name), + ], + domainConfigMap: [ + (selectors) => [selectors.domainConfigs], + (domainConfigs: DomainConfig[]) => + domainConfigs.reduce( + (acc, domainConfig) => ({ ...acc, [domainConfig.name]: domainConfig }), + {} as { [key: string]: DomainConfig } + ), + ], + entryPointUrls: [ + (selectors) => [selectors.domainConfigMap, selectors.selectedDomainUrls], + (domainConfigMap: { [key: string]: DomainConfig }, selectedDomainUrls: string[]): string[] => + selectedDomainUrls.flatMap( + (selectedDomainUrl) => domainConfigMap[selectedDomainUrl].seedUrls + ), + ], + sitemapUrls: [ + (selectors) => [selectors.domainConfigMap, selectors.selectedDomainUrls], + (domainConfigMap: { [key: string]: DomainConfig }, selectedDomainUrls: string[]): string[] => + selectedDomainUrls.flatMap( + (selectedDomainUrl) => domainConfigMap[selectedDomainUrl].sitemapUrls + ), + ], + }), + listeners: ({ actions, values }) => ({ + fetchDomainConfigData: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const { results } = await http.get<{ + results: DomainConfigFromServer[]; + }>(`/internal/app_search/engines/${engineName}/crawler/domain_configs`); + + const domainConfigs = results.map(domainConfigServerToClient); + actions.onRecieveDomainConfigData(domainConfigs); + } catch (e) { + flashAPIErrors(e); + } + }, + showFlyout: () => { + actions.fetchDomainConfigData(); + }, + startCustomCrawl: () => { + CrawlerLogic.actions.startCrawl({ + domain_allowlist: values.selectedDomainUrls, + max_crawl_depth: values.maxCrawlDepth, + seed_urls: [...values.selectedEntryPointUrls, ...values.customEntryPointUrls], + sitemap_urls: [...values.selectedSitemapUrls, ...values.customSitemapUrls], + sitemap_discovery_disabled: !values.includeSitemapsInRobotsTxt, + }); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_seed_urls_panel.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_seed_urls_panel.test.tsx new file mode 100644 index 0000000000000..71da3f8c596e4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_seed_urls_panel.test.tsx @@ -0,0 +1,165 @@ +/* + * 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 { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiAccordion, EuiTabbedContent, EuiNotificationBadge, EuiCheckbox } from '@elastic/eui'; + +import { rerender } from '../../../../../test_helpers'; +import { SimplifiedSelectable } from '../crawl_select_domains_modal/simplified_selectable'; + +import { CrawlCustomSettingsFlyoutSeedUrlsPanel } from './crawl_custom_settings_flyout_seed_urls_panel'; +import { UrlComboBox } from './url_combo_box'; + +const MOCK_VALUES = { + // CrawlCustomSettingsFlyoutLogic + customEntryPointUrls: ['https://www.elastic.co/custom-entry-point'], + customSitemapUrls: [ + 'https://www.elastic.co/custom-sitemap1.xml', + 'https://swiftype.com/custom-sitemap2.xml', + ], + entryPointUrls: ['https://www.elastic.co/guide', 'https://swiftype.com/documentation'], + selectedDomainUrls: ['https://www.elastic.co', 'https://swiftype.com'], + selectedEntryPointUrls: ['https://swiftype.com/documentation'], + selectedSitemapUrls: ['https://www.elastic.co/sitemap1.xml', 'https://swiftype.com/sitemap2.xml'], + sitemapUrls: [ + 'https://www.elastic.co/sitemap1.xml', + 'https://www.elastic.co/sitemap2.xml', + 'https://swiftype.com/sitemap1.xml', + 'https://swiftype.com/sitemap2.xml', + ], + includeSitemapsInRobotsTxt: true, +}; + +const MOCK_ACTIONS = { + // CrawlCustomSettingsFlyoutLogic + onSelectCustomEntryPointUrls: jest.fn(), + onSelectCustomSitemapUrls: jest.fn(), + onSelectEntryPointUrls: jest.fn(), + onSelectSitemapUrls: jest.fn(), + toggleIncludeSitemapsInRobotsTxt: jest.fn(), +}; + +const getAccordionBadge = (wrapper: ShallowWrapper) => { + const accordionWrapper = wrapper.find(EuiAccordion); + const extraActionWrapper = shallow(
{accordionWrapper.prop('extraAction')}
); + return extraActionWrapper.find(EuiNotificationBadge); +}; + +describe('CrawlCustomSettingsFlyoutSeedUrlsPanel', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(MOCK_VALUES); + setMockActions(MOCK_ACTIONS); + + wrapper = shallow(); + }); + + describe('sitemaps tab', () => { + let sitemapTab: ShallowWrapper; + + beforeEach(() => { + const tabs = wrapper.find(EuiTabbedContent).prop('tabs'); + sitemapTab = shallow(
{tabs[0].content}
); + }); + + it('allows the user to select sitemap urls', () => { + expect(sitemapTab.find(SimplifiedSelectable).props()).toEqual({ + options: MOCK_VALUES.sitemapUrls, + selectedOptions: MOCK_VALUES.selectedSitemapUrls, + onChange: MOCK_ACTIONS.onSelectSitemapUrls, + }); + }); + + it('allows the user to toggle whether to include robots.txt sitemaps', () => { + expect(sitemapTab.find(EuiCheckbox).props()).toEqual( + expect.objectContaining({ + onChange: MOCK_ACTIONS.toggleIncludeSitemapsInRobotsTxt, + checked: true, + }) + ); + }); + + it('allows the user to add custom sitemap urls', () => { + expect(sitemapTab.find(UrlComboBox).props()).toEqual( + expect.objectContaining({ + selectedUrls: MOCK_VALUES.customSitemapUrls, + onChange: MOCK_ACTIONS.onSelectCustomSitemapUrls, + }) + ); + }); + }); + + describe('entry points tab', () => { + let entryPointsTab: ShallowWrapper; + + beforeEach(() => { + const tabs = wrapper.find(EuiTabbedContent).prop('tabs'); + entryPointsTab = shallow(
{tabs[1].content}
); + }); + + it('allows the user to select entry point urls', () => { + expect(entryPointsTab.find(SimplifiedSelectable).props()).toEqual({ + options: MOCK_VALUES.entryPointUrls, + selectedOptions: MOCK_VALUES.selectedEntryPointUrls, + onChange: MOCK_ACTIONS.onSelectEntryPointUrls, + }); + }); + + it('allows the user to add custom entry point urls', () => { + expect(entryPointsTab.find(UrlComboBox).props()).toEqual( + expect.objectContaining({ + selectedUrls: MOCK_VALUES.customEntryPointUrls, + onChange: MOCK_ACTIONS.onSelectCustomEntryPointUrls, + }) + ); + }); + }); + + it('indicates how many seed urls are selected', () => { + let badge = getAccordionBadge(wrapper); + + expect(badge.render().text()).toContain('6'); + expect(badge.prop('color')).toEqual('accent'); + + setMockValues({ + ...MOCK_VALUES, + customEntryPointUrls: [], + customSitemapUrls: [], + selectedEntryPointUrls: [], + selectedSitemapUrls: [], + }); + + rerender(wrapper); + badge = getAccordionBadge(wrapper); + + expect(badge.render().text()).toContain('0'); + expect(badge.prop('color')).toEqual('subdued'); + }); + + it('shows empty messages when the user has not selected any domains', () => { + setMockValues({ + ...MOCK_VALUES, + selectedDomainUrls: [], + }); + + rerender(wrapper); + + const tabs = wrapper.find(EuiTabbedContent).prop('tabs'); + const sitemapsTab = shallow(
{tabs[0].content}
); + const entryPointsTab = shallow(
{tabs[1].content}
); + + expect(sitemapsTab.find(SimplifiedSelectable).prop('emptyMessage')).toBeDefined(); + expect(entryPointsTab.find(SimplifiedSelectable).prop('emptyMessage')).toBeDefined(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_seed_urls_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_seed_urls_panel.tsx new file mode 100644 index 0000000000000..457a544f91582 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/crawl_custom_settings_flyout_seed_urls_panel.tsx @@ -0,0 +1,205 @@ +/* + * 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 { useValues, useActions } from 'kea'; + +import { + EuiAccordion, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiNotificationBadge, + EuiPanel, + EuiSpacer, + EuiTabbedContent, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { SimplifiedSelectable } from '../crawl_select_domains_modal/simplified_selectable'; + +import { CrawlCustomSettingsFlyoutLogic } from './crawl_custom_settings_flyout_logic'; +import { UrlComboBox } from './url_combo_box'; + +export const CrawlCustomSettingsFlyoutSeedUrlsPanel: React.FC = () => { + const { + customEntryPointUrls, + customSitemapUrls, + entryPointUrls, + includeSitemapsInRobotsTxt, + selectedDomainUrls, + selectedEntryPointUrls, + selectedSitemapUrls, + sitemapUrls, + } = useValues(CrawlCustomSettingsFlyoutLogic); + const { + onSelectCustomEntryPointUrls, + onSelectCustomSitemapUrls, + onSelectEntryPointUrls, + onSelectSitemapUrls, + toggleIncludeSitemapsInRobotsTxt, + } = useActions(CrawlCustomSettingsFlyoutLogic); + + const totalSeedUrls = + customEntryPointUrls.length + + customSitemapUrls.length + + selectedEntryPointUrls.length + + selectedSitemapUrls.length; + + return ( + + + + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlCustomSettingsFlyout.seedUrlsAccordionButtonLabel', + { + defaultMessage: 'Seed URLs', + } + )} +

+
+
+ + } + extraAction={ + + 0 ? 'accent' : 'subdued'}> + {totalSeedUrls} + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlCustomSettingsFlyout.selectedDescriptor', + { + defaultMessage: 'selected', + } + )} + + + } + > + + + + robots.txt, // this is a technical term and shouldn't be translated + }} + /> + } + checked={includeSitemapsInRobotsTxt} + onChange={toggleIncludeSitemapsInRobotsTxt} + /> + + + + + + ), + }, + { + id: useGeneratedHtmlId({ prefix: 'entryPointsTab' }), + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.crawlCustomSettingsFlyout.entryPointsTabLabel', + { + defaultMessage: 'Entry points', + } + ), + content: ( + <> + + + + + + ), + }, + ]} + autoFocus="selected" + /> +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box.scss new file mode 100644 index 0000000000000..8abd104e83844 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box.scss @@ -0,0 +1,5 @@ +.urlComboBox { + .euiComboBox__inputWrap { + min-height: 60px; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box.test.tsx new file mode 100644 index 0000000000000..831b6066b21ea --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box.test.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 { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; + +jest.mock('@elastic/eui', () => ({ + ...(jest.requireActual('@elastic/eui') as object), + useGeneratedHtmlId: jest.fn(() => 'test id'), +})); + +import React from 'react'; + +import { ShallowWrapper, shallow } from 'enzyme'; + +import { EuiComboBox, EuiFormRow } from '@elastic/eui'; + +import { rerender } from '../../../../../test_helpers'; + +import { UrlComboBox } from './url_combo_box'; + +const DEFAULT_PROPS = { + label: 'label text', + selectedUrls: ['https://www.elastic.co'], + onChange: jest.fn(), +}; + +const MOCK_VALUES = { + // UrlComboBoxLogic + isInvalid: false, +}; + +const MOCK_ACTIONS = { + // UrlComboBoxLogic + setIsInvalid: jest.fn(), +}; + +describe('UrlComboBox', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(MOCK_VALUES); + setMockActions(MOCK_ACTIONS); + wrapper = shallow(); + }); + + it('is valid by default', () => { + expect(wrapper.find(EuiFormRow).prop('error')).toBeUndefined(); + expect(wrapper.find(EuiComboBox).prop('isInvalid')).toEqual(false); + }); + + it('shows error messages when invalid', () => { + setMockValues({ + isInvalid: true, + }); + + rerender(wrapper); + + expect(wrapper.find(EuiFormRow).prop('error')).toBeDefined(); + expect(wrapper.find(EuiComboBox).prop('isInvalid')).toEqual(true); + }); + + it('shows selected urls', () => { + expect(wrapper.find(EuiComboBox).prop('selectedOptions')).toEqual([ + { label: 'https://www.elastic.co' }, + ]); + }); + + it('clears the invalid flag when the user types', () => { + const onSearchChange = wrapper.find(EuiComboBox).prop('onSearchChange'); + + if (onSearchChange) { + onSearchChange('htt'); + } + + expect(MOCK_ACTIONS.setIsInvalid).toHaveBeenCalledWith(false); + }); + + it('returns selected urls in a callback on change', () => { + const onChange = wrapper.find(EuiComboBox).prop('onChange'); + + if (onChange) { + onChange([{ label: 'https://elastic.co' }]); + } + + expect(DEFAULT_PROPS.onChange).toHaveBeenCalledWith(['https://elastic.co']); + }); + + it('fails validation when user submits an invalid url', () => { + const onCreateOption = wrapper.find(EuiComboBox).prop('onCreateOption'); + + if (onCreateOption) { + onCreateOption('not a url', []); + } + + expect(MOCK_ACTIONS.setIsInvalid).toHaveBeenCalledWith(true); + }); + + it('fails validation when user submits a url with an invalid protocol', () => { + const onCreateOption = wrapper.find(EuiComboBox).prop('onCreateOption'); + + if (onCreateOption) { + onCreateOption('invalidprotocol://swiftype.com', []); + } + + expect(MOCK_ACTIONS.setIsInvalid).toHaveBeenCalledWith(true); + }); + + it('includes the new url with already selected url in a callback on create', () => { + const onCreateOption = wrapper.find(EuiComboBox).prop('onCreateOption'); + + if (onCreateOption) { + onCreateOption('https://swiftype.com', []); + } + + expect(MOCK_ACTIONS.setIsInvalid).toHaveBeenCalledWith(false); + expect(DEFAULT_PROPS.onChange).toHaveBeenCalledWith([ + 'https://www.elastic.co', + 'https://swiftype.com', + ]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box.tsx new file mode 100644 index 0000000000000..0bcb2afaf896e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import { useValues, useActions } from 'kea'; + +import { EuiFormRow, EuiComboBox, useGeneratedHtmlId } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { UrlComboBoxLogic } from './url_combo_box_logic'; + +import './url_combo_box.scss'; + +const isUrl = (value: string) => { + let url; + + try { + url = new URL(value); + } catch (_) { + return false; + } + + return url.protocol === 'http:' || url.protocol === 'https:'; +}; + +interface Props { + label: string; + selectedUrls: string[]; + onChange(selectedUrls: string[]): void; +} + +export const UrlComboBox: React.FC = ({ label, selectedUrls, onChange }) => { + const id = useGeneratedHtmlId(); + const urlComboBoxLogic = UrlComboBoxLogic({ id }); + const { isInvalid } = useValues(urlComboBoxLogic); + const { setIsInvalid } = useActions(urlComboBoxLogic); + + return ( + + ({ label: selectedUrl }))} + onCreateOption={(newUrl) => { + if (!isUrl(newUrl)) { + setIsInvalid(true); + // Return false to explicitly reject the user's input. + return false; + } + + setIsInvalid(false); + + onChange([...selectedUrls, newUrl]); + }} + onSearchChange={() => { + setIsInvalid(false); + }} + onChange={(newOptions) => { + onChange(newOptions.map((newOption) => newOption.label)); + }} + isInvalid={isInvalid} + /> + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box_logic.test.ts new file mode 100644 index 0000000000000..c4b48bc01e363 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box_logic.test.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 { LogicMounter } from '../../../../../__mocks__/kea_logic'; + +import { UrlComboBoxLogic } from './url_combo_box_logic'; + +describe('UrlComboBoxLogic', () => { + const { mount } = new LogicMounter(UrlComboBoxLogic); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + const urlComboLogic = mount({}, { id: 'test id' }); + + expect(urlComboLogic.values).toEqual({ + isInvalid: false, + }); + }); + + describe('actions', () => { + describe('setIsInvalid', () => { + it('saves the value', () => { + const urlComboLogic = mount({}, { id: 'test id' }); + + urlComboLogic.actions.setIsInvalid(true); + + expect(urlComboLogic.values.isInvalid).toEqual(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box_logic.ts new file mode 100644 index 0000000000000..18c471bf1ba81 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_custom_settings_flyout/url_combo_box_logic.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 { kea, MakeLogicType } from 'kea'; + +export interface UrlComboBoxValues { + isInvalid: boolean; +} + +export interface UrlComboBoxActions { + setIsInvalid(isInvalid: boolean): { isInvalid: boolean }; +} + +export const UrlComboBoxLogic = kea>({ + key: (props) => props.id, + path: (key: string) => ['enterprise_search', 'app_search', 'url_combo_box', key], + actions: () => ({ + setIsInvalid: (isInvalid) => ({ isInvalid }), + }), + reducers: () => ({ + isInvalid: [ + false, + { + setIsInvalid: (_, { isInvalid }) => isInvalid, + }, + ], + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_select_domains_modal/simplified_selectable.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_select_domains_modal/simplified_selectable.test.tsx index a90259f8dac3c..7a564988f1859 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_select_domains_modal/simplified_selectable.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_select_domains_modal/simplified_selectable.test.tsx @@ -27,6 +27,7 @@ describe('SimplifiedSelectable', () => { options={['cat', 'dog', 'fish']} selectedOptions={['cat', 'fish']} onChange={MOCK_ON_CHANGE} + emptyMessage={'empty message'} /> ); }); @@ -47,6 +48,10 @@ describe('SimplifiedSelectable', () => { ]); }); + it('passes on an empty message', () => { + expect(wrapper.find(EuiSelectable).prop('emptyMessage')).toEqual('empty message'); + }); + it('passes newly selected options to the callback', () => { wrapper.find(EuiSelectable).simulate('change', [ { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_select_domains_modal/simplified_selectable.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_select_domains_modal/simplified_selectable.tsx index 07ede1c59971a..e13304b4a8f2f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_select_domains_modal/simplified_selectable.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawl_select_domains_modal/simplified_selectable.tsx @@ -11,6 +11,7 @@ import { EuiSelectableLIOption } from '@elastic/eui/src/components/selectable/se import { i18n } from '@kbn/i18n'; export interface Props { + emptyMessage?: string; options: string[]; selectedOptions: string[]; onChange(selectedOptions: string[]): void; @@ -20,7 +21,12 @@ export interface OptionMap { [key: string]: boolean; } -export const SimplifiedSelectable: React.FC = ({ options, selectedOptions, onChange }) => { +export const SimplifiedSelectable: React.FC = ({ + emptyMessage, + options, + selectedOptions, + onChange, +}) => { const selectedOptionsMap: OptionMap = selectedOptions.reduce( (acc, selectedOption) => ({ ...acc, @@ -77,6 +83,7 @@ export const SimplifiedSelectable: React.FC = ({ options, selectedOptions newSelectableOptions.filter((option) => option.checked).map((option) => option.label) ); }} + emptyMessage={emptyMessage} > {(list, search) => ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/start_crawl_context_menu.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/start_crawl_context_menu.test.tsx index 6d9f1cd7be64b..c867564cc480d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/start_crawl_context_menu.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/start_crawl_context_menu.test.tsx @@ -24,7 +24,11 @@ import { mountWithIntl } from '../../../../../test_helpers'; import { StartCrawlContextMenu } from './start_crawl_context_menu'; const MOCK_ACTIONS = { + // CrawlerLogic startCrawl: jest.fn(), + // CrawlCustomSettingsFlyoutLogic + showFlyout: jest.fn(), + // CrawlSelectDomainsModalLogic showModal: jest.fn(), }; @@ -58,7 +62,7 @@ describe('StartCrawlContextMenu', () => { it('can be opened', () => { expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(true); - expect(menuItems.length).toEqual(2); + expect(menuItems.length).toEqual(3); }); it('can start crawls', () => { @@ -72,5 +76,11 @@ describe('StartCrawlContextMenu', () => { expect(MOCK_ACTIONS.showModal).toHaveBeenCalled(); }); + + it('can open a modal to start a crawl with custom settings', () => { + menuItems.at(2).simulate('click'); + + expect(MOCK_ACTIONS.showFlyout).toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/start_crawl_context_menu.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/start_crawl_context_menu.tsx index 1182a845bd4f7..57a91a11bb6b3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/start_crawl_context_menu.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/start_crawl_context_menu.tsx @@ -13,6 +13,7 @@ import { i18n } from '@kbn/i18n'; import { CrawlerLogic } from '../../crawler_logic'; +import { CrawlCustomSettingsFlyoutLogic } from '../crawl_custom_settings_flyout/crawl_custom_settings_flyout_logic'; import { CrawlSelectDomainsModalLogic } from '../crawl_select_domains_modal/crawl_select_domains_modal_logic'; interface Props { @@ -23,7 +24,7 @@ interface Props { export const StartCrawlContextMenu: React.FC = ({ menuButtonLabel, fill }) => { const { startCrawl } = useActions(CrawlerLogic); const { showModal: showCrawlSelectDomainsModal } = useActions(CrawlSelectDomainsModalLogic); - + const { showFlyout: showCrawlCustomSettingsFlyout } = useActions(CrawlCustomSettingsFlyoutLogic); const [isPopoverOpen, setPopover] = useState(false); const togglePopover = () => setPopover(!isPopoverOpen); @@ -72,6 +73,20 @@ export const StartCrawlContextMenu: React.FC = ({ menuButtonLabel, fill } } )} , + { + closePopover(); + showCrawlCustomSettingsFlyout(); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.startCrawlContextMenu.crawlCustomSettingsMenuLabel', + { + defaultMessage: 'Crawl with custom settings', + } + )} + , ]} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_logic.ts index d68dbc59f06d0..68b1cb6ec9b26 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_logic.ts @@ -33,6 +33,14 @@ const ACTIVE_STATUSES = [ CrawlerStatus.Canceling, ]; +interface CrawlRequestOverrides { + domain_allowlist?: string[]; + max_crawl_depth?: number; + seed_urls?: string[]; + sitemap_urls?: string[]; + sitemap_discovery_disabled?: boolean; +} + export interface CrawlerValues { events: CrawlEvent[]; dataLoading: boolean; @@ -49,7 +57,7 @@ interface CrawlerActions { onCreateNewTimeout(timeoutId: NodeJS.Timeout): { timeoutId: NodeJS.Timeout }; onReceiveCrawlerData(data: CrawlerData): { data: CrawlerData }; onStartCrawlRequestComplete(): void; - startCrawl(overrides?: object): { overrides?: object }; + startCrawl(overrides?: CrawlRequestOverrides): { overrides?: CrawlRequestOverrides }; stopCrawl(): void; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx index f1f25dfb4dc55..13a13c25a5ad8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx @@ -22,6 +22,7 @@ import { AddDomainForm } from './components/add_domain/add_domain_form'; import { AddDomainFormErrors } from './components/add_domain/add_domain_form_errors'; import { AddDomainFormSubmitButton } from './components/add_domain/add_domain_form_submit_button'; import { AddDomainLogic } from './components/add_domain/add_domain_logic'; +import { CrawlCustomSettingsFlyout } from './components/crawl_custom_settings_flyout/crawl_custom_settings_flyout'; import { CrawlDetailsFlyout } from './components/crawl_details_flyout'; import { CrawlRequestsTable } from './components/crawl_requests_table'; import { CrawlSelectDomainsModal } from './components/crawl_select_domains_modal/crawl_select_domains_modal'; @@ -140,6 +141,7 @@ export const CrawlerOverview: React.FC = () => { )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx index 63b9c3f080ec2..6baa1c9aee10c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_single_domain.tsx @@ -16,6 +16,7 @@ import { EuiPanel, EuiSpacer } from '@elastic/eui'; import { EngineLogic, getEngineBreadcrumbs } from '../engine'; import { AppSearchPageTemplate } from '../layout'; +import { CrawlCustomSettingsFlyout } from './components/crawl_custom_settings_flyout/crawl_custom_settings_flyout'; import { CrawlRulesTable } from './components/crawl_rules_table'; import { CrawlSelectDomainsModal } from './components/crawl_select_domains_modal/crawl_select_domains_modal'; import { CrawlerStatusBanner } from './components/crawler_status_banner'; @@ -80,6 +81,7 @@ export const CrawlerSingleDomain: React.FC = () => { + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts index 3d8881601ae1a..9dc133cb682a7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts @@ -330,3 +330,17 @@ export enum CrawlUnits { weeks = 'week', months = 'month', } + +export interface DomainConfigFromServer { + id: string; + name: string; + seed_urls: string[]; + sitemap_urls: string[]; +} + +export interface DomainConfig { + id: string; + name: string; + seedUrls: string[]; + sitemapUrls: string[]; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts index cab4023370291..cded3b539c527 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts @@ -36,6 +36,7 @@ import { getDeleteDomainSuccessMessage, getCrawlRulePathPatternTooltip, crawlRequestStatsServerToClient, + domainConfigServerToClient, } from './utils'; const DEFAULT_CRAWL_RULE: CrawlRule = { @@ -499,3 +500,21 @@ describe('getCrawlRulePathPatternTooltip', () => { expect(getCrawlRulePathPatternTooltip(crawlRule)).toContain('meta'); }); }); + +describe('domainConfigServerToClient', () => { + it('converts the domain config payload into properties matching our code style', () => { + expect( + domainConfigServerToClient({ + id: '1234', + name: 'https://www.elastic.co', + seed_urls: [], + sitemap_urls: [], + }) + ).toEqual({ + id: '1234', + name: 'https://www.elastic.co', + seedUrls: [], + sitemapUrls: [], + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts index 4819b073cccb3..98af1222c17ed 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts @@ -26,6 +26,8 @@ import { CrawlConfig, CrawlRequestWithDetailsFromServer, CrawlRequestWithDetails, + DomainConfig, + DomainConfigFromServer, } from './types'; export function crawlerDomainServerToClient(payload: CrawlerDomainFromServer): CrawlerDomain { @@ -257,3 +259,12 @@ export const getCrawlRulePathPatternTooltip = (crawlRule: CrawlRule) => { } ); }; + +export const domainConfigServerToClient = ( + domainConfigFromServer: DomainConfigFromServer +): DomainConfig => ({ + id: domainConfigFromServer.id, + name: domainConfigFromServer.name, + seedUrls: domainConfigFromServer.seed_urls, + sitemapUrls: domainConfigFromServer.sitemap_urls, +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts index fe225f62d1dce..3e56b75d01e93 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts @@ -141,10 +141,42 @@ describe('crawler routes', () => { mockRouter.shouldValidate(request); }); - it('validates correctly with overrides', () => { + it('validates correctly with domain urls', () => { const request = { params: { name: 'some-engine' }, - body: { overrides: { domain_allowlist: [] } }, + body: { overrides: { domain_allowlist: ['https://www.elastic.co'] } }, + }; + mockRouter.shouldValidate(request); + }); + + it('validates correctly with max crawl depth', () => { + const request = { + params: { name: 'some-engine' }, + body: { overrides: { max_crawl_depth: 10 } }, + }; + mockRouter.shouldValidate(request); + }); + + it('validates correctly with seed urls', () => { + const request = { + params: { name: 'some-engine' }, + body: { overrides: { seed_urls: ['https://www.elastic.co/guide'] } }, + }; + mockRouter.shouldValidate(request); + }); + + it('validates correctly with sitemap urls', () => { + const request = { + params: { name: 'some-engine' }, + body: { overrides: { sitemap_urls: ['https://www.elastic.co/sitemap1.xml'] } }, + }; + mockRouter.shouldValidate(request); + }); + + it('validates correctly when we set sitemap discovery', () => { + const request = { + params: { name: 'some-engine' }, + body: { overrides: { sitemap_discovery_disabled: true } }, }; mockRouter.shouldValidate(request); }); @@ -642,4 +674,37 @@ describe('crawler routes', () => { mockRouter.shouldThrow(request); }); }); + + describe('GET /internal/app_search/engines/{name}/crawler/domain_configs', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/internal/app_search/engines/{name}/crawler/domain_configs', + }); + + registerCrawlerRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v1/engines/:name/crawler/domain_configs', + }); + }); + + it('validates correctly with name', () => { + const request = { params: { name: 'some-engine' } }; + mockRouter.shouldValidate(request); + }); + + it('fails validation without name', () => { + const request = { params: {} }; + mockRouter.shouldThrow(request); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts index 5adffe1ff3ee5..00aabc02a7586 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts @@ -67,6 +67,10 @@ export function registerCrawlerRoutes({ overrides: schema.maybe( schema.object({ domain_allowlist: schema.maybe(schema.arrayOf(schema.string())), + max_crawl_depth: schema.maybe(schema.number()), + seed_urls: schema.maybe(schema.arrayOf(schema.string())), + sitemap_urls: schema.maybe(schema.arrayOf(schema.string())), + sitemap_discovery_disabled: schema.maybe(schema.boolean()), }) ), }), @@ -271,4 +275,18 @@ export function registerCrawlerRoutes({ path: '/api/as/v1/engines/:name/crawler/crawl_schedule', }) ); + + router.get( + { + path: '/internal/app_search/engines/{name}/crawler/domain_configs', + validate: { + params: schema.object({ + name: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v1/engines/:name/crawler/domain_configs', + }) + ); } From 81c5fbf538eca80bab149f9dba1c97cfb1610564 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 15 Feb 2022 16:05:01 -0700 Subject: [PATCH 34/39] [Security Solutions] Exposes the search_after and point in time (pit) from saved objects to exception lists (#125182) ## Summary Exposes the functionality of * search_after * point in time (pit) From saved objects to the exception lists. This _DOES NOT_ expose these to the REST API just yet. Rather this exposes it at the API level to start with and changes code that had hard limits of 10k and other limited loops. I use the batching of 1k for this at a time as I thought that would be a decent batch guess and I see other parts of the code changed to it. It's easy to change the 1k if we find we need to throttle back more as we get feedback from others. See this PR where `PIT` and `search_after` were first introduced: https://github.com/elastic/kibana/pull/89915 See these 2 issues where we should be using more paging and PIT (Point in Time) with search_after: https://github.com/elastic/kibana/issues/93770 https://github.com/elastic/kibana/issues/103944 The new methods added to the `exception_list_client.ts` client class are: * openPointInTime * closePointInTime * findExceptionListItemPointInTimeFinder * findExceptionListPointInTimeFinder * findExceptionListsItemPointInTimeFinder * findValueListExceptionListItemsPointInTimeFinder The areas of functionality that have been changed: * Exception list exports * Deletion of lists * Getting exception list items when generating signals Note that currently we use our own ways of looping over the saved objects which you can see in the codebase such as this older way below which does work but had a limitation of 10k against saved objects and did not do point in time (PIT) Older way example (deprecated): ```ts let page = 1; let ids: string[] = []; let foundExceptionListItems = await findExceptionListItem({ filter: undefined, listId, namespaceType, page, perPage: PER_PAGE, pit: undefined, savedObjectsClient, searchAfter: undefined, sortField: 'tie_breaker_id', sortOrder: 'desc', }); while (foundExceptionListItems != null && foundExceptionListItems.data.length > 0) { ids = [ ...ids, ...foundExceptionListItems.data.map((exceptionListItem) => exceptionListItem.id), ]; page += 1; foundExceptionListItems = await findExceptionListItem({ filter: undefined, listId, namespaceType, page, perPage: PER_PAGE, pit: undefined, savedObjectsClient, searchAfter: undefined, sortField: 'tie_breaker_id', sortOrder: 'desc', }); } return ids; ``` But now that is replaced with this newer way using PIT: ```ts // Stream the results from the Point In Time (PIT) finder into this array let ids: string[] = []; const executeFunctionOnStream = (response: FoundExceptionListItemSchema): void => { const responseIds = response.data.map((exceptionListItem) => exceptionListItem.id); ids = [...ids, ...responseIds]; }; await findExceptionListItemPointInTimeFinder({ executeFunctionOnStream, filter: undefined, listId, maxSize: undefined, // NOTE: This is unbounded when it is "undefined" namespaceType, perPage: 1_000, savedObjectsClient, sortField: 'tie_breaker_id', sortOrder: 'desc', }); return ids; ``` We also have areas of code that has perPage listed at 10k or a constant that represents 10k which this removes in most areas (but not all areas): ```ts const items = await client.findExceptionListsItem({ listId: listIds, namespaceType: namespaceTypes, page: 1, pit: undefined, perPage: MAX_EXCEPTION_LIST_SIZE, // <--- Really bad to send in 10k per page at a time searchAfter: undefined, filter: [], sortOrder: undefined, sortField: undefined, }); ``` That is now: ```ts // Stream the results from the Point In Time (PIT) finder into this array let items: ExceptionListItemSchema[] = []; const executeFunctionOnStream = (response: FoundExceptionListItemSchema): void => { items = [...items, ...response.data]; }; await client.findExceptionListsItemPointInTimeFinder({ executeFunctionOnStream, listId: listIds, namespaceType: namespaceTypes, perPage: 1_000, filter: [], maxSize: undefined, // NOTE: This is unbounded when it is "undefined" sortOrder: undefined, sortField: undefined, }); ``` Left over areas will be handled in separate PR's because they are in other people's code ownership areas. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../src/common/index.ts | 3 + .../src/common/max_size/index.test.ts | 59 ++++ .../src/common/max_size/index.ts | 18 ++ .../src/common/pit/index.test.ts | 65 ++++ .../src/common/pit/index.ts | 22 ++ .../src/common/search_after/index.test.ts | 56 ++++ .../src/common/search_after/index.ts | 17 + .../found_exception_list_item_schema/index.ts | 24 +- .../found_exception_list_schema/index.ts | 20 +- .../lists/server/routes/delete_list_route.ts | 59 ++-- .../routes/find_endpoint_list_item_route.ts | 2 + .../routes/find_exception_list_item_route.ts | 2 + .../routes/find_exception_list_route.ts | 2 + .../plugins/lists/server/routes/validate.ts | 2 + .../exception_lists/close_point_in_time.ts | 31 ++ .../delete_exception_list_items_by_list.ts | 44 +-- .../exception_list_client.test.ts | 4 + .../exception_lists/exception_list_client.ts | 304 +++++++++++++++++- .../exception_list_client_types.ts | 78 ++++- .../export_exception_list_and_items.test.ts | 25 +- .../export_exception_list_and_items.ts | 23 +- .../find_exception_list.test.ts | 68 ---- .../exception_lists/find_exception_list.ts | 31 +- .../find_exception_list_item.ts | 10 +- ...xception_list_item_point_in_time_finder.ts | 89 +++++ .../find_exception_list_items.test.ts | 106 ------ .../find_exception_list_items.ts | 75 +---- ...ception_list_items_point_in_time_finder.ts | 142 ++++++++ ...ind_exception_list_point_in_time_finder.ts | 118 +++++++ .../find_value_list_exception_list_items.ts | 64 ++++ ...ception_list_items_point_in_time_finder.ts | 117 +++++++ .../exception_lists/open_point_in_time.ts | 37 +++ .../utils/get_exception_list_filter.test.ts | 66 ++++ .../utils/get_exception_list_filter.ts | 25 ++ .../get_exception_lists_item_filter.test.ts | 104 ++++++ .../utils/get_exception_lists_item_filter.ts | 36 +++ .../find_all_exception_list_item_types.ts | 2 +- .../import/find_all_exception_list_types.ts | 6 + .../services/exception_lists/utils/index.ts | 2 + .../services/items/get_list_item_by_values.ts | 3 + .../items/search_list_item_by_values.ts | 3 + .../detection_engine/signals/utils.test.ts | 60 ++-- .../lib/detection_engine/signals/utils.ts | 49 +-- 43 files changed, 1666 insertions(+), 407 deletions(-) create mode 100644 packages/kbn-securitysolution-io-ts-list-types/src/common/max_size/index.test.ts create mode 100644 packages/kbn-securitysolution-io-ts-list-types/src/common/max_size/index.ts create mode 100644 packages/kbn-securitysolution-io-ts-list-types/src/common/pit/index.test.ts create mode 100644 packages/kbn-securitysolution-io-ts-list-types/src/common/pit/index.ts create mode 100644 packages/kbn-securitysolution-io-ts-list-types/src/common/search_after/index.test.ts create mode 100644 packages/kbn-securitysolution-io-ts-list-types/src/common/search_after/index.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/close_point_in_time.ts delete mode 100644 x-pack/plugins/lists/server/services/exception_lists/find_exception_list.test.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item_point_in_time_finder.ts delete mode 100644 x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items_point_in_time_finder.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/find_exception_list_point_in_time_finder.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/find_value_list_exception_list_items.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/find_value_list_exception_list_items_point_in_time_finder.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/open_point_in_time.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_list_filter.test.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_list_filter.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.test.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.ts diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/index.ts index 81ecd58cb397c..98c160e2c4302 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/index.ts @@ -35,6 +35,7 @@ export * from './list_operator'; export * from './list_type'; export * from './lists'; export * from './lists_default_array'; +export * from './max_size'; export * from './meta'; export * from './name'; export * from './non_empty_entries_array'; @@ -42,6 +43,8 @@ export * from './non_empty_nested_entries_array'; export * from './os_type'; export * from './page'; export * from './per_page'; +export * from './pit'; +export * from './search_after'; export * from './serializer'; export * from './sort_field'; export * from './sort_order'; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/max_size/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/max_size/index.test.ts new file mode 100644 index 0000000000000..459195e3ec27f --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/max_size/index.test.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 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 { exactCheck } from '@kbn/securitysolution-io-ts-utils'; +import { maxSizeOrUndefined } from '.'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; + +describe('maxSizeOrUndefined', () => { + test('it will validate a correct max value', () => { + const payload = 123; + const decoded = maxSizeOrUndefined.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it will fail to validate a 0', () => { + const payload = 0; + const decoded = maxSizeOrUndefined.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "0" supplied to "(PositiveIntegerGreaterThanZero | undefined)"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it will fail to validate a -1', () => { + const payload = -1; + const decoded = maxSizeOrUndefined.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "(PositiveIntegerGreaterThanZero | undefined)"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it will fail to validate a string', () => { + const payload = '123'; + const decoded = maxSizeOrUndefined.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "123" supplied to "(PositiveIntegerGreaterThanZero | undefined)"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/max_size/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/max_size/index.ts new file mode 100644 index 0000000000000..59ae5b7b7fc63 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/max_size/index.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. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { PositiveIntegerGreaterThanZero } from '@kbn/securitysolution-io-ts-types'; +import * as t from 'io-ts'; + +export const max_size = PositiveIntegerGreaterThanZero; +export type MaxSize = t.TypeOf; + +export const maxSizeOrUndefined = t.union([max_size, t.undefined]); +export type MaxSizeOrUndefined = t.TypeOf; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/pit/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/pit/index.test.ts new file mode 100644 index 0000000000000..19aeb0690f13e --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/pit/index.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { exactCheck } from '@kbn/securitysolution-io-ts-utils'; +import { pitOrUndefined } from '.'; + +import * as t from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; + +describe('pitOrUndefined', () => { + test('it will validate a correct pit', () => { + const payload = { id: '123', keepAlive: '1m' }; + const decoded = pitOrUndefined.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it will validate with the value of "undefined"', () => { + const obj = t.exact( + t.type({ + pit_id: pitOrUndefined, + }) + ); + const payload: t.TypeOf = { + pit_id: undefined, + }; + const decoded = obj.decode({ + pit_id: undefined, + }); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it will validate a correct pit without having a "keepAlive"', () => { + const payload = { id: '123' }; + const decoded = pitOrUndefined.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it will fail to validate an incorrect pit', () => { + const payload = 'foo'; + const decoded = pitOrUndefined.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "foo" supplied to "({| id: string, keepAlive: (string | undefined) |} | undefined)"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/pit/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/pit/index.ts new file mode 100644 index 0000000000000..773794edaf1f6 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/pit/index.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 * as t from 'io-ts'; + +export const pitId = t.string; +export const pit = t.exact( + t.type({ + id: pitId, + keepAlive: t.union([t.string, t.undefined]), + }) +); +export const pitOrUndefined = t.union([pit, t.undefined]); + +export type Pit = t.TypeOf; +export type PitId = t.TypeOf; +export type PitOrUndefined = t.TypeOf; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/search_after/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/search_after/index.test.ts new file mode 100644 index 0000000000000..135aa53d39783 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/search_after/index.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { exactCheck } from '@kbn/securitysolution-io-ts-utils'; +import { searchAfterOrUndefined } from '.'; + +import * as t from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; + +describe('searchAfter', () => { + test('it will validate a correct search_after', () => { + const payload = ['test-1', 'test-2']; + const decoded = searchAfterOrUndefined.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it will validate with the value of "undefined"', () => { + const obj = t.exact( + t.type({ + search_after: searchAfterOrUndefined, + }) + ); + const payload: t.TypeOf = { + search_after: undefined, + }; + const decoded = obj.decode({ + pit_id: undefined, + }); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it will fail to validate an incorrect search_after', () => { + const payload = 'foo'; + const decoded = searchAfterOrUndefined.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "foo" supplied to "(Array | undefined)"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/search_after/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/search_after/index.ts new file mode 100644 index 0000000000000..ef39716e5bcac --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/search_after/index.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 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. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import * as t from 'io-ts'; + +export const search_after = t.array(t.string); +export type SearchAfter = t.TypeOf; + +export const searchAfterOrUndefined = t.union([search_after, t.undefined]); +export type SearchAfterOrUndefined = t.TypeOf; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/response/found_exception_list_item_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/response/found_exception_list_item_schema/index.ts index df82a70ef626c..587c39c385f91 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/response/found_exception_list_item_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/response/found_exception_list_item_schema/index.ts @@ -10,16 +10,24 @@ import * as t from 'io-ts'; import { page } from '../../common/page'; import { per_page } from '../../common/per_page'; +import { pitId } from '../../common/pit'; import { total } from '../../common/total'; import { exceptionListItemSchema } from '../exception_list_item_schema'; -export const foundExceptionListItemSchema = t.exact( - t.type({ - data: t.array(exceptionListItemSchema), - page, - per_page, - total, - }) -); +export const foundExceptionListItemSchema = t.intersection([ + t.exact( + t.type({ + data: t.array(exceptionListItemSchema), + page, + per_page, + total, + }) + ), + t.exact( + t.partial({ + pit: pitId, + }) + ), +]); export type FoundExceptionListItemSchema = t.TypeOf; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/response/found_exception_list_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/response/found_exception_list_schema/index.ts index 4e430f607fb04..07b4090af9425 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/response/found_exception_list_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/response/found_exception_list_schema/index.ts @@ -10,17 +10,21 @@ import * as t from 'io-ts'; import { page } from '../../common/page'; import { per_page } from '../../common/per_page'; +import { pitId } from '../../common/pit'; import { total } from '../../common/total'; import { exceptionListSchema } from '../exception_list_schema'; -export const foundExceptionListSchema = t.exact( - t.type({ - data: t.array(exceptionListSchema), - page, - per_page, - total, - }) -); +export const foundExceptionListSchema = t.intersection([ + t.exact( + t.type({ + data: t.array(exceptionListSchema), + page, + per_page, + total, + }) + ), + t.exact(t.partial({ pit: pitId })), +]); export type FoundExceptionListSchema = t.TypeOf; diff --git a/x-pack/plugins/lists/server/routes/delete_list_route.ts b/x-pack/plugins/lists/server/routes/delete_list_route.ts index 35ac490826703..6ddcce94d82e8 100644 --- a/x-pack/plugins/lists/server/routes/delete_list_route.ts +++ b/x-pack/plugins/lists/server/routes/delete_list_route.ts @@ -10,6 +10,8 @@ import { transformError } from '@kbn/securitysolution-es-utils'; import { EntriesArray, ExceptionListItemSchema, + ExceptionListSchema, + FoundExceptionListItemSchema, FoundExceptionListSchema, deleteListSchema, exceptionListItemSchema, @@ -19,7 +21,7 @@ import { getSavedObjectType } from '@kbn/securitysolution-list-utils'; import { LIST_URL } from '@kbn/securitysolution-list-constants'; import type { ListsPluginRouter } from '../types'; -import { ExceptionListClient } from '../services/exception_lists/exception_list_client'; +import type { ExceptionListClient } from '../services/exception_lists/exception_list_client'; import { escapeQuotes } from '../services/utils/escape_query'; import { buildRouteValidation, buildSiemResponse } from './utils'; @@ -47,26 +49,31 @@ export const deleteListRoute = (router: ListsPluginRouter): void => { // ignoreReferences=true maintains pre-7.11 behavior of deleting value list without performing any additional checks if (!ignoreReferences) { - const referencedExceptionListItems = await exceptionLists.findValueListExceptionListItems( - { - page: 1, - perPage: 10000, - sortField: undefined, - sortOrder: undefined, - valueListId: id, - } - ); + // Stream the results from the Point In Time (PIT) finder into this array + let referencedExceptionListItems: ExceptionListItemSchema[] = []; + const executeFunctionOnStream = (foundResponse: FoundExceptionListItemSchema): void => { + referencedExceptionListItems = [...referencedExceptionListItems, ...foundResponse.data]; + }; - if (referencedExceptionListItems?.data?.length) { + await exceptionLists.findValueListExceptionListItemsPointInTimeFinder({ + executeFunctionOnStream, + maxSize: undefined, // NOTE: This is unbounded when it is "undefined" + perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k + sortField: undefined, + sortOrder: undefined, + valueListId: id, + }); + if (referencedExceptionListItems.length) { // deleteReferences=false to perform dry run and identify referenced exception lists/items if (deleteReferences) { // Delete referenced exception list items // TODO: Create deleteListItems to delete in batch deleteExceptionItemResponses = await Promise.all( - referencedExceptionListItems.data.map(async (listItem) => { + referencedExceptionListItems.map(async (listItem) => { // Ensure only the single entry is deleted as there could be a separate value list referenced that is okay to keep // TODO: Add API to delete single entry - // @ts-ignore inline way of verifying entry type is EntryList? - const remainingEntries = listItem.entries.filter((e) => e?.list?.id !== id); + const remainingEntries = listItem.entries.filter( + (e) => e.type === 'list' && e.list.id !== id + ); if (remainingEntries.length === 0) { // All entries reference value list specified in request, delete entire exception list item return deleteExceptionListItem(exceptionLists, listItem); @@ -79,14 +86,12 @@ export const deleteListRoute = (router: ListsPluginRouter): void => { } else { const referencedExceptionLists = await getReferencedExceptionLists( exceptionLists, - referencedExceptionListItems.data + referencedExceptionListItems ); const refError = `Value list '${id}' is referenced in existing exception list(s)`; - const references = referencedExceptionListItems.data.map((item) => ({ + const references = referencedExceptionListItems.map((item) => ({ exception_item: item, - exception_list: referencedExceptionLists.data.find( - (l) => l.list_id === item.list_id - ), + exception_list: referencedExceptionLists.find((l) => l.list_id === item.list_id), })); return siemResponse.error({ @@ -140,7 +145,7 @@ export const deleteListRoute = (router: ListsPluginRouter): void => { const getReferencedExceptionLists = async ( exceptionLists: ExceptionListClient, exceptionListItems: ExceptionListItemSchema[] -): Promise => { +): Promise => { const filter = exceptionListItems .map( (item) => @@ -149,14 +154,22 @@ const getReferencedExceptionLists = async ( })}.attributes.list_id: "${escapeQuotes(item.list_id)}"` ) .join(' OR '); - return exceptionLists.findExceptionList({ + + // Stream the results from the Point In Time (PIT) finder into this array + let exceptionList: ExceptionListSchema[] = []; + const executeFunctionOnStream = (response: FoundExceptionListSchema): void => { + exceptionList = [...exceptionList, ...response.data]; + }; + await exceptionLists.findExceptionListPointInTimeFinder({ + executeFunctionOnStream, filter: `(${filter})`, + maxSize: undefined, // NOTE: This is unbounded when it is "undefined" namespaceType: ['agnostic', 'single'], - page: 1, - perPage: 10000, + perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k sortField: undefined, sortOrder: undefined, }); + return exceptionList; }; /** diff --git a/x-pack/plugins/lists/server/routes/find_endpoint_list_item_route.ts b/x-pack/plugins/lists/server/routes/find_endpoint_list_item_route.ts index 36b5a66c2830f..6516e88877384 100644 --- a/x-pack/plugins/lists/server/routes/find_endpoint_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/find_endpoint_list_item_route.ts @@ -48,6 +48,8 @@ export const findEndpointListItemRoute = (router: ListsPluginRouter): void => { filter, page, perPage, + pit: undefined, + searchAfter: undefined, sortField, sortOrder, }); diff --git a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts index fe7ffaa066281..67450ca02bb20 100644 --- a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts @@ -58,6 +58,8 @@ export const findExceptionListItemRoute = (router: ListsPluginRouter): void => { namespaceType, page, perPage, + pit: undefined, + searchAfter: undefined, sortField, sortOrder, }); diff --git a/x-pack/plugins/lists/server/routes/find_exception_list_route.ts b/x-pack/plugins/lists/server/routes/find_exception_list_route.ts index 5d1b78747a89e..e49b9c39d2f04 100644 --- a/x-pack/plugins/lists/server/routes/find_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/find_exception_list_route.ts @@ -48,6 +48,8 @@ export const findExceptionListRoute = (router: ListsPluginRouter): void => { namespaceType, page, perPage, + pit: undefined, + searchAfter: undefined, sortField, sortOrder, }); diff --git a/x-pack/plugins/lists/server/routes/validate.ts b/x-pack/plugins/lists/server/routes/validate.ts index 5a118bf2c5ae0..29b2dd3b06d28 100644 --- a/x-pack/plugins/lists/server/routes/validate.ts +++ b/x-pack/plugins/lists/server/routes/validate.ts @@ -30,6 +30,8 @@ export const validateExceptionListSize = async ( namespaceType, page: undefined, perPage: undefined, + pit: undefined, + searchAfter: undefined, sortField: undefined, sortOrder: undefined, }); diff --git a/x-pack/plugins/lists/server/services/exception_lists/close_point_in_time.ts b/x-pack/plugins/lists/server/services/exception_lists/close_point_in_time.ts new file mode 100644 index 0000000000000..0fbcd08316166 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/close_point_in_time.ts @@ -0,0 +1,31 @@ +/* + * 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 { + SavedObjectsClientContract, + SavedObjectsClosePointInTimeResponse, +} from 'kibana/server'; +import type { PitId } from '@kbn/securitysolution-io-ts-list-types'; + +interface ClosePointInTimeOptions { + pit: PitId; + savedObjectsClient: SavedObjectsClientContract; +} + +/** + * Closes a point in time (PIT) for either exception lists or exception list items. + * See: https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * @params pit {string} The point in time to close + * @params savedObjectsClient {object} The saved objects client to delegate to + * @return {SavedObjectsOpenPointInTimeResponse} The point in time (PIT) + */ +export const closePointInTime = async ({ + pit, + savedObjectsClient, +}: ClosePointInTimeOptions): Promise => { + return savedObjectsClient.closePointInTime(pit); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_items_by_list.ts b/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_items_by_list.ts index 873fe66b1ad3b..c23c8967fef68 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_items_by_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_items_by_list.ts @@ -5,15 +5,16 @@ * 2.0. */ -import type { ListId, NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; +import type { + FoundExceptionListItemSchema, + ListId, + NamespaceType, +} from '@kbn/securitysolution-io-ts-list-types'; import { getSavedObjectType } from '@kbn/securitysolution-list-utils'; import { asyncForEach } from '@kbn/std'; +import type { SavedObjectsClientContract } from 'kibana/server'; -import { SavedObjectsClientContract } from '../../../../../../src/core/server'; - -import { findExceptionListItem } from './find_exception_list_item'; - -const PER_PAGE = 100; +import { findExceptionListItemPointInTimeFinder } from './find_exception_list_item_point_in_time_finder'; interface DeleteExceptionListItemByListOptions { listId: ListId; @@ -35,35 +36,24 @@ export const getExceptionListItemIds = async ({ savedObjectsClient, namespaceType, }: DeleteExceptionListItemByListOptions): Promise => { - let page = 1; + // Stream the results from the Point In Time (PIT) finder into this array let ids: string[] = []; - let foundExceptionListItems = await findExceptionListItem({ + const executeFunctionOnStream = (response: FoundExceptionListItemSchema): void => { + const responseIds = response.data.map((exceptionListItem) => exceptionListItem.id); + ids = [...ids, ...responseIds]; + }; + + await findExceptionListItemPointInTimeFinder({ + executeFunctionOnStream, filter: undefined, listId, + maxSize: undefined, // NOTE: This is unbounded when it is "undefined" namespaceType, - page, - perPage: PER_PAGE, + perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k savedObjectsClient, sortField: 'tie_breaker_id', sortOrder: 'desc', }); - while (foundExceptionListItems != null && foundExceptionListItems.data.length > 0) { - ids = [ - ...ids, - ...foundExceptionListItems.data.map((exceptionListItem) => exceptionListItem.id), - ]; - page += 1; - foundExceptionListItems = await findExceptionListItem({ - filter: undefined, - listId, - namespaceType, - page, - perPage: PER_PAGE, - savedObjectsClient, - sortField: 'tie_breaker_id', - sortOrder: 'desc', - }); - } return ids; }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.test.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.test.ts index 3b6f2cb6ae4f2..1a444c403d3c2 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.test.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.test.ts @@ -212,6 +212,8 @@ describe('exception_list_client', () => { namespaceType: 'agnostic', page: 1, perPage: 1, + pit: undefined, + searchAfter: undefined, sortField: 'name', sortOrder: 'asc', }); @@ -229,6 +231,8 @@ describe('exception_list_client', () => { namespaceType: ['agnostic'], page: 1, perPage: 1, + pit: undefined, + searchAfter: undefined, sortField: 'name', sortOrder: 'asc', }); diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 93f5077f021d5..d6fc1bfec8058 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import type { + KibanaRequest, + SavedObjectsClientContract, + SavedObjectsClosePointInTimeResponse, + SavedObjectsOpenPointInTimeResponse, +} from 'kibana/server'; import { ExceptionListItemSchema, ExceptionListSchema, @@ -24,7 +29,8 @@ import type { ServerExtensionCallbackContext, } from '../extension_points'; -import { +import type { + ClosePointInTimeOptions, ConstructorOptions, CreateEndpointListItemOptions, CreateExceptionListItemOptions, @@ -36,15 +42,20 @@ import { ExportExceptionListAndItemsOptions, FindEndpointListItemOptions, FindExceptionListItemOptions, + FindExceptionListItemPointInTimeFinderOptions, + FindExceptionListItemsPointInTimeFinderOptions, FindExceptionListOptions, + FindExceptionListPointInTimeFinderOptions, FindExceptionListsItemOptions, FindValueListExceptionListsItems, + FindValueListExceptionListsItemsPointInTimeFinder, GetEndpointListItemOptions, GetExceptionListItemOptions, GetExceptionListOptions, GetExceptionListSummaryOptions, ImportExceptionListAndItemsAsArrayOptions, ImportExceptionListAndItemsOptions, + OpenPointInTimeOptions, UpdateEndpointListItemOptions, UpdateExceptionListItemOptions, UpdateExceptionListOptions, @@ -64,10 +75,7 @@ import { deleteExceptionList } from './delete_exception_list'; import { deleteExceptionListItem, deleteExceptionListItemById } from './delete_exception_list_item'; import { findExceptionListItem } from './find_exception_list_item'; import { findExceptionList } from './find_exception_list'; -import { - findExceptionListsItem, - findValueListExceptionListItems, -} from './find_exception_list_items'; +import { findExceptionListsItem } from './find_exception_list_items'; import { createEndpointList } from './create_endpoint_list'; import { createEndpointTrustedAppsList } from './create_endpoint_trusted_apps_list'; import { PromiseFromStreams, importExceptions } from './import_exception_list_and_items'; @@ -80,6 +88,13 @@ import { createExceptionsStreamFromNdjson, exceptionsChecksFromArray, } from './utils/import/create_exceptions_stream_logic'; +import { openPointInTime } from './open_point_in_time'; +import { closePointInTime } from './close_point_in_time'; +import { findExceptionListPointInTimeFinder } from './find_exception_list_point_in_time_finder'; +import { findValueListExceptionListItems } from './find_value_list_exception_list_items'; +import { findExceptionListsItemPointInTimeFinder } from './find_exception_list_items_point_in_time_finder'; +import { findValueListExceptionListItemsPointInTimeFinder } from './find_value_list_exception_list_items_point_in_time_finder'; +import { findExceptionListItemPointInTimeFinder } from './find_exception_list_item_point_in_time_finder'; export class ExceptionListClient { private readonly user: string; @@ -621,7 +636,9 @@ export class ExceptionListClient { listId, filter, perPage, + pit, page, + searchAfter, sortField, sortOrder, namespaceType, @@ -637,6 +654,8 @@ export class ExceptionListClient { namespaceType, page, perPage, + pit, + searchAfter, sortField, sortOrder, }, @@ -650,7 +669,9 @@ export class ExceptionListClient { namespaceType, page, perPage, + pit, savedObjectsClient, + searchAfter, sortField, sortOrder, }); @@ -660,7 +681,9 @@ export class ExceptionListClient { listId, filter, perPage, + pit, page, + searchAfter, sortField, sortOrder, namespaceType, @@ -676,6 +699,8 @@ export class ExceptionListClient { namespaceType, page, perPage, + pit, + searchAfter, sortField, sortOrder, }, @@ -689,7 +714,9 @@ export class ExceptionListClient { namespaceType, page, perPage, + pit, savedObjectsClient, + searchAfter, sortField, sortOrder, }); @@ -697,7 +724,9 @@ export class ExceptionListClient { public findValueListExceptionListItems = async ({ perPage, + pit, page, + searchAfter, sortField, sortOrder, valueListId, @@ -706,7 +735,9 @@ export class ExceptionListClient { return findValueListExceptionListItems({ page, perPage, + pit, savedObjectsClient, + searchAfter, sortField, sortOrder, valueListId, @@ -717,6 +748,8 @@ export class ExceptionListClient { filter, perPage, page, + pit, + searchAfter, sortField, sortOrder, namespaceType, @@ -727,7 +760,9 @@ export class ExceptionListClient { namespaceType, page, perPage, + pit, savedObjectsClient, + searchAfter, sortField, sortOrder, }); @@ -745,6 +780,8 @@ export class ExceptionListClient { filter, perPage, page, + pit, + searchAfter, sortField, sortOrder, }: FindEndpointListItemOptions): Promise => { @@ -756,7 +793,9 @@ export class ExceptionListClient { namespaceType: 'agnostic', page, perPage, + pit, savedObjectsClient, + searchAfter, sortField, sortOrder, }); @@ -865,4 +904,257 @@ export class ExceptionListClient { user, }); }; + + /** + * Opens a point in time (PIT) for either exception lists or exception list items. + * See: https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * @params namespaceType {string} "agnostic" or "single" depending on which namespace you are targeting + * @params options {Object} The saved object PIT options + * @return {SavedObjectsOpenPointInTimeResponse} The point in time (PIT) + */ + public openPointInTime = async ({ + namespaceType, + options, + }: OpenPointInTimeOptions): Promise => { + const { savedObjectsClient } = this; + return openPointInTime({ + namespaceType, + options, + savedObjectsClient, + }); + }; + + /** + * Closes a point in time (PIT) for either exception lists or exception list items. + * See: https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * @params pit {string} The point in time to close + * @return {SavedObjectsOpenPointInTimeResponse} The point in time (PIT) + */ + public closePointInTime = async ({ + pit, + }: ClosePointInTimeOptions): Promise => { + const { savedObjectsClient } = this; + return closePointInTime({ + pit, + savedObjectsClient, + }); + }; + + /** + * Finds an exception list item within a point in time (PIT) and then calls the function + * `executeFunctionOnStream` until the maxPerPage is reached and stops. + * NOTE: This is slightly different from the saved objects version in that it takes + * an injected function, so that we avoid doing additional plumbing with generators + * to try to keep the maintenance of this machinery simpler for now. + * + * If you want to stream all results up to 10k into memory for correlation this would be: + * @example + * ```ts + * const exceptionList: ExceptionListItemSchema[] = []; + * const executeFunctionOnStream = (response: FoundExceptionListItemSchema) => { + * exceptionList = [...exceptionList, ...response.data]; + * } + * await client.findExceptionListItemPointInTimeFinder({ + * filter, + * executeFunctionOnStream, + * namespaceType, + * maxSize: 10_000, // NOTE: This is unbounded if it is "undefined" + * perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k + * sortField, + * sortOrder, + * exe + * }); + * ``` + * @param filter {string} Your filter + * @param namespaceType {string} "agnostic" | "single" of your namespace + * @param perPage {number} The number of items per page. Typical value should be 1_000 here. Never go above 10_000 + * @param maxSize {number of undefined} If given a max size, this will not exceeded. Otherwise if undefined is passed down, all records will be processed. + * @param sortField {string} String of the field to sort against + * @param sortOrder "asc" | "desc" The order to sort against + */ + public findExceptionListItemPointInTimeFinder = async ({ + executeFunctionOnStream, + filter, + listId, + maxSize, + namespaceType, + perPage, + sortField, + sortOrder, + }: FindExceptionListItemPointInTimeFinderOptions): Promise => { + const { savedObjectsClient } = this; + return findExceptionListItemPointInTimeFinder({ + executeFunctionOnStream, + filter, + listId, + maxSize, + namespaceType, + perPage, + savedObjectsClient, + sortField, + sortOrder, + }); + }; + + /** + * Finds an exception list within a point in time (PIT) and then calls the function + * `executeFunctionOnStream` until the maxPerPage is reached and stops. + * NOTE: This is slightly different from the saved objects version in that it takes + * an injected function, so that we avoid doing additional plumbing with generators + * to try to keep the maintenance of this machinery simpler for now. + * + * If you want to stream all results up to 10k into memory for correlation this would be: + * @example + * ```ts + * const exceptionList: ExceptionListSchema[] = []; + * const executeFunctionOnStream = (response: FoundExceptionListSchema) => { + * exceptionList = [...exceptionList, ...response.data]; + * } + * await client.findExceptionListPointInTimeFinder({ + * filter, + * executeFunctionOnStream, + * namespaceType, + * maxSize: 10_000, // NOTE: This is unbounded if it is "undefined" + * perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k + * sortField, + * sortOrder, + * exe + * }); + * ``` + * @param filter {string} Your filter + * @param namespaceType {string} "agnostic" | "single" of your namespace + * @param perPage {number} The number of items per page. Typical value should be 1_000 here. Never go above 10_000 + * @param maxSize {number of undefined} If given a max size, this will not exceeded. Otherwise if undefined is passed down, all records will be processed. + * @param sortField {string} String of the field to sort against + * @param sortOrder "asc" | "desc" The order to sort against + */ + public findExceptionListPointInTimeFinder = async ({ + executeFunctionOnStream, + filter, + maxSize, + namespaceType, + perPage, + sortField, + sortOrder, + }: FindExceptionListPointInTimeFinderOptions): Promise => { + const { savedObjectsClient } = this; + return findExceptionListPointInTimeFinder({ + executeFunctionOnStream, + filter, + maxSize, + namespaceType, + perPage, + savedObjectsClient, + sortField, + sortOrder, + }); + }; + + /** + * Finds exception list items within a point in time (PIT) and then calls the function + * `executeFunctionOnStream` until the maxPerPage is reached and stops. + * NOTE: This is slightly different from the saved objects version in that it takes + * an injected function, so that we avoid doing additional plumbing with generators + * to try to keep the maintenance of this machinery simpler for now. + * + * If you want to stream all results up to 10k into memory for correlation this would be: + * @example + * ```ts + * const exceptionList: ExceptionListItemSchema[] = []; + * const executeFunctionOnStream = (response: FoundExceptionListItemSchema) => { + * exceptionList = [...exceptionList, ...response.data]; + * } + * await client.findExceptionListsItemPointInTimeFinder({ + * filter, + * executeFunctionOnStream, + * namespaceType, + * maxSize: 10_000, // NOTE: This is unbounded if it is "undefined" + * perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k + * sortField, + * sortOrder, + * exe + * }); + * ``` + * @param filter {string} Your filter + * @param namespaceType {string} "agnostic" | "single" of your namespace + * @param perPage {number} The number of items per page. Typical value should be 1_000 here. Never go above 10_000 + * @param maxSize {number of undefined} If given a max size, this will not exceeded. Otherwise if undefined is passed down, all records will be processed. + * @param sortField {string} String of the field to sort against + * @param sortOrder "asc" | "desc" The order to sort against + */ + public findExceptionListsItemPointInTimeFinder = async ({ + listId, + namespaceType, + executeFunctionOnStream, + maxSize, + filter, + perPage, + sortField, + sortOrder, + }: FindExceptionListItemsPointInTimeFinderOptions): Promise => { + const { savedObjectsClient } = this; + return findExceptionListsItemPointInTimeFinder({ + executeFunctionOnStream, + filter, + listId, + maxSize, + namespaceType, + perPage, + savedObjectsClient, + sortField, + sortOrder, + }); + }; + + /** + * Finds value lists within exception lists within a point in time (PIT) and then calls the function + * `executeFunctionOnStream` until the maxPerPage is reached and stops. + * NOTE: This is slightly different from the saved objects version in that it takes + * an injected function, so that we avoid doing additional plumbing with generators + * to try to keep the maintenance of this machinery simpler for now. + * + * If you want to stream all results up to 10k into memory for correlation this would be: + * @example + * ```ts + * const exceptionList: ExceptionListItemSchema[] = []; + * const executeFunctionOnStream = (response: FoundExceptionListItemSchema) => { + * exceptionList = [...exceptionList, ...response.data]; + * } + * await client.findValueListExceptionListItemsPointInTimeFinder({ + * valueListId, + * executeFunctionOnStream, + * namespaceType, + * maxSize: 10_000, // NOTE: This is unbounded if it is "undefined" + * perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k + * sortField, + * sortOrder, + * exe + * }); + * ``` + * @param valueListId {string} Your value list id + * @param namespaceType {string} "agnostic" | "single" of your namespace + * @param perPage {number} The number of items per page. Typical value should be 1_000 here. Never go above 10_000 + * @param maxSize {number of undefined} If given a max size, this will not exceeded. Otherwise if undefined is passed down, all records will be processed. + * @param sortField {string} String of the field to sort against + * @param sortOrder "asc" | "desc" The order to sort against + */ + public findValueListExceptionListItemsPointInTimeFinder = async ({ + valueListId, + executeFunctionOnStream, + perPage, + maxSize, + sortField, + sortOrder, + }: FindValueListExceptionListsItemsPointInTimeFinder): Promise => { + const { savedObjectsClient } = this; + return findValueListExceptionListItemsPointInTimeFinder({ + executeFunctionOnStream, + maxSize, + perPage, + savedObjectsClient, + sortField, + sortOrder, + valueListId, + }); + }; } diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 1c2762cb52c97..4c7820fc05f94 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -5,9 +5,13 @@ * 2.0. */ -import { Readable } from 'stream'; +import type { Readable } from 'stream'; -import type { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import type { + KibanaRequest, + SavedObjectsClientContract, + SavedObjectsOpenPointInTimeOptions, +} from 'kibana/server'; import type { CreateCommentsArray, Description, @@ -19,6 +23,8 @@ import type { ExceptionListTypeOrUndefined, ExportExceptionDetails, FilterOrUndefined, + FoundExceptionListItemSchema, + FoundExceptionListSchema, Id, IdOrUndefined, Immutable, @@ -28,6 +34,7 @@ import type { ItemIdOrUndefined, ListId, ListIdOrUndefined, + MaxSizeOrUndefined, MetaOrUndefined, Name, NameOrUndefined, @@ -36,6 +43,9 @@ import type { OsTypeArray, PageOrUndefined, PerPageOrUndefined, + PitId, + PitOrUndefined, + SearchAfterOrUndefined, SortFieldOrUndefined, SortOrderOrUndefined, Tags, @@ -43,14 +53,14 @@ import type { UpdateCommentsArray, _VersionOrUndefined, } from '@kbn/securitysolution-io-ts-list-types'; -import { +import type { EmptyStringArrayDecoded, NonEmptyStringArrayDecoded, Version, VersionOrUndefined, } from '@kbn/securitysolution-io-ts-types'; -import { ExtensionPointStorageClientInterface } from '../extension_points'; +import type { ExtensionPointStorageClientInterface } from '../extension_points'; export interface ConstructorOptions { user: string; @@ -194,6 +204,8 @@ export interface FindExceptionListItemOptions { namespaceType: NamespaceType; filter: FilterOrUndefined; perPage: PerPageOrUndefined; + pit?: PitOrUndefined; + searchAfter?: SearchAfterOrUndefined; page: PageOrUndefined; sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; @@ -202,6 +214,8 @@ export interface FindExceptionListItemOptions { export interface FindEndpointListItemOptions { filter: FilterOrUndefined; perPage: PerPageOrUndefined; + pit?: PitOrUndefined; + searchAfter?: SearchAfterOrUndefined; page: PageOrUndefined; sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; @@ -212,6 +226,8 @@ export interface FindExceptionListsItemOptions { namespaceType: NamespaceTypeArray; filter: EmptyStringArrayDecoded; perPage: PerPageOrUndefined; + pit?: PitOrUndefined; + searchAfter?: SearchAfterOrUndefined; page: PageOrUndefined; sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; @@ -220,6 +236,8 @@ export interface FindExceptionListsItemOptions { export interface FindValueListExceptionListsItems { valueListId: Id; perPage: PerPageOrUndefined; + pit?: PitOrUndefined; + searchAfter?: SearchAfterOrUndefined; page: PageOrUndefined; sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; @@ -230,6 +248,8 @@ export interface FindExceptionListOptions { filter: FilterOrUndefined; perPage: PerPageOrUndefined; page: PageOrUndefined; + pit?: PitOrUndefined; + searchAfter?: SearchAfterOrUndefined; sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; } @@ -256,3 +276,53 @@ export interface ImportExceptionListAndItemsAsArrayOptions { maxExceptionsImportSize: number; overwrite: boolean; } + +export interface OpenPointInTimeOptions { + namespaceType: NamespaceType; + options: SavedObjectsOpenPointInTimeOptions | undefined; +} + +export interface ClosePointInTimeOptions { + pit: PitId; +} + +export interface FindExceptionListItemPointInTimeFinderOptions { + listId: ListId; + namespaceType: NamespaceType; + filter: FilterOrUndefined; + perPage: PerPageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; + executeFunctionOnStream: (response: FoundExceptionListItemSchema) => void; + maxSize: MaxSizeOrUndefined; +} + +export interface FindExceptionListPointInTimeFinderOptions { + maxSize: MaxSizeOrUndefined; + namespaceType: NamespaceTypeArray; + filter: FilterOrUndefined; + perPage: PerPageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; + executeFunctionOnStream: (response: FoundExceptionListSchema) => void; +} + +export interface FindExceptionListItemsPointInTimeFinderOptions { + listId: NonEmptyStringArrayDecoded; + namespaceType: NamespaceTypeArray; + filter: EmptyStringArrayDecoded; + perPage: PerPageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; + executeFunctionOnStream: (response: FoundExceptionListItemSchema) => void; + maxSize: MaxSizeOrUndefined; +} + +export interface FindValueListExceptionListsItemsPointInTimeFinder { + valueListId: Id; + perPage: PerPageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; + executeFunctionOnStream: (response: FoundExceptionListItemSchema) => void; + maxSize: MaxSizeOrUndefined; +} diff --git a/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.test.ts index 9f3c02fecca20..0f8e4bf7bbf45 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.test.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.test.ts @@ -5,44 +5,49 @@ * 2.0. */ -import { SavedObjectsClientContract } from 'kibana/server'; +import { savedObjectsClientMock } from 'src/core/server/mocks'; import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock'; import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; import { exportExceptionListAndItems } from './export_exception_list_and_items'; -import { findExceptionListItem } from './find_exception_list_item'; +import { findExceptionListItemPointInTimeFinder } from './find_exception_list_item_point_in_time_finder'; import { getExceptionList } from './get_exception_list'; jest.mock('./get_exception_list'); -jest.mock('./find_exception_list_item'); +jest.mock('./find_exception_list_item_point_in_time_finder'); describe('export_exception_list_and_items', () => { describe('exportExceptionListAndItems', () => { test('it should return null if no matching exception list found', async () => { (getExceptionList as jest.Mock).mockResolvedValue(null); - (findExceptionListItem as jest.Mock).mockResolvedValue({ data: [] }); + (findExceptionListItemPointInTimeFinder as jest.Mock).mockImplementationOnce( + ({ executeFunctionOnStream }) => { + executeFunctionOnStream({ data: [getExceptionListItemSchemaMock()] }); + } + ); const result = await exportExceptionListAndItems({ id: '123', listId: 'non-existent', namespaceType: 'single', - savedObjectsClient: {} as SavedObjectsClientContract, + savedObjectsClient: savedObjectsClientMock.create(), }); expect(result).toBeNull(); }); test('it should return stringified list and items', async () => { (getExceptionList as jest.Mock).mockResolvedValue(getExceptionListSchemaMock()); - (findExceptionListItem as jest.Mock).mockResolvedValue({ - data: [getExceptionListItemSchemaMock()], - }); - + (findExceptionListItemPointInTimeFinder as jest.Mock).mockImplementationOnce( + ({ executeFunctionOnStream }) => { + executeFunctionOnStream({ data: [getExceptionListItemSchemaMock()] }); + } + ); const result = await exportExceptionListAndItems({ id: '123', listId: 'non-existent', namespaceType: 'single', - savedObjectsClient: {} as SavedObjectsClientContract, + savedObjectsClient: savedObjectsClientMock.create(), }); expect(result?.exportData).toEqual( `${JSON.stringify(getExceptionListSchemaMock())}\n${JSON.stringify( diff --git a/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.ts b/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.ts index b071c72a9b122..b2cd7d38d8d56 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.ts @@ -6,15 +6,17 @@ */ import type { + ExceptionListItemSchema, ExportExceptionDetails, + FoundExceptionListItemSchema, IdOrUndefined, ListIdOrUndefined, NamespaceType, } from '@kbn/securitysolution-io-ts-list-types'; import { transformDataToNdjson } from '@kbn/securitysolution-utils'; -import { SavedObjectsClientContract } from 'kibana/server'; +import type { SavedObjectsClientContract } from 'kibana/server'; -import { findExceptionListItem } from './find_exception_list_item'; +import { findExceptionListItemPointInTimeFinder } from './find_exception_list_item_point_in_time_finder'; import { getExceptionList } from './get_exception_list'; interface ExportExceptionListAndItemsOptions { @@ -45,20 +47,23 @@ export const exportExceptionListAndItems = async ({ if (exceptionList == null) { return null; } else { - // TODO: Will need to address this when we switch over to - // using PIT, don't want it to get lost - // https://github.com/elastic/kibana/issues/103944 - const listItems = await findExceptionListItem({ + // Stream the results from the Point In Time (PIT) finder into this array + let exceptionItems: ExceptionListItemSchema[] = []; + const executeFunctionOnStream = (response: FoundExceptionListItemSchema): void => { + exceptionItems = [...exceptionItems, ...response.data]; + }; + + await findExceptionListItemPointInTimeFinder({ + executeFunctionOnStream, filter: undefined, listId: exceptionList.list_id, + maxSize: undefined, // NOTE: This is unbounded when it is "undefined" namespaceType: exceptionList.namespace_type, - page: 1, - perPage: 10000, + perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k savedObjectsClient, sortField: 'exception-list.created_at', sortOrder: 'desc', }); - const exceptionItems = listItems?.data ?? []; const { exportData } = getExport([exceptionList, ...exceptionItems]); // TODO: Add logic for missing lists and items on errors diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.test.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.test.ts deleted file mode 100644 index 919a1e4e90a51..0000000000000 --- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.test.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 { getExceptionListFilter } from './find_exception_list'; - -describe('find_exception_list', () => { - describe('getExceptionListFilter', () => { - test('it should create a filter for agnostic lists if only searching for agnostic lists', () => { - const filter = getExceptionListFilter({ - filter: undefined, - savedObjectTypes: ['exception-list-agnostic'], - }); - expect(filter).toEqual('(exception-list-agnostic.attributes.list_type: list)'); - }); - - test('it should create a filter for agnostic lists with additional filters if only searching for agnostic lists', () => { - const filter = getExceptionListFilter({ - filter: 'exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"', - savedObjectTypes: ['exception-list-agnostic'], - }); - expect(filter).toEqual( - '(exception-list-agnostic.attributes.list_type: list) AND exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"' - ); - }); - - test('it should create a filter for single lists if only searching for single lists', () => { - const filter = getExceptionListFilter({ - filter: undefined, - savedObjectTypes: ['exception-list'], - }); - expect(filter).toEqual('(exception-list.attributes.list_type: list)'); - }); - - test('it should create a filter for single lists with additional filters if only searching for single lists', () => { - const filter = getExceptionListFilter({ - filter: 'exception-list.attributes.name: "Sample Endpoint Exception List"', - savedObjectTypes: ['exception-list'], - }); - expect(filter).toEqual( - '(exception-list.attributes.list_type: list) AND exception-list.attributes.name: "Sample Endpoint Exception List"' - ); - }); - - test('it should create a filter that searches for both agnostic and single lists', () => { - const filter = getExceptionListFilter({ - filter: undefined, - savedObjectTypes: ['exception-list-agnostic', 'exception-list'], - }); - expect(filter).toEqual( - '(exception-list-agnostic.attributes.list_type: list OR exception-list.attributes.list_type: list)' - ); - }); - - test('it should create a filter that searches for both agnostic and single lists with additional filters if only searching for agnostic lists', () => { - const filter = getExceptionListFilter({ - filter: 'exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"', - savedObjectTypes: ['exception-list-agnostic', 'exception-list'], - }); - expect(filter).toEqual( - '(exception-list-agnostic.attributes.list_type: list OR exception-list.attributes.list_type: list) AND exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"' - ); - }); - }); -}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.ts index b3d5dd9ddb32b..46d292e93a2d1 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.ts @@ -5,21 +5,24 @@ * 2.0. */ -import { SavedObjectsClientContract } from 'kibana/server'; +import type { SavedObjectsClientContract } from 'kibana/server'; import type { FilterOrUndefined, FoundExceptionListSchema, NamespaceTypeArray, PageOrUndefined, PerPageOrUndefined, + PitOrUndefined, + SearchAfterOrUndefined, SortFieldOrUndefined, SortOrderOrUndefined, } from '@kbn/securitysolution-io-ts-list-types'; -import { SavedObjectType, getSavedObjectTypes } from '@kbn/securitysolution-list-utils'; +import { getSavedObjectTypes } from '@kbn/securitysolution-list-utils'; -import { ExceptionListSoSchema } from '../../schemas/saved_objects'; +import type { ExceptionListSoSchema } from '../../schemas/saved_objects'; import { transformSavedObjectsToFoundExceptionList } from './utils'; +import { getExceptionListFilter } from './utils/get_exception_list_filter'; interface FindExceptionListOptions { namespaceType: NamespaceTypeArray; @@ -29,6 +32,8 @@ interface FindExceptionListOptions { page: PageOrUndefined; sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; + pit: PitOrUndefined; + searchAfter: SearchAfterOrUndefined; } export const findExceptionList = async ({ @@ -37,14 +42,18 @@ export const findExceptionList = async ({ filter, page, perPage, + searchAfter, sortField, sortOrder, + pit, }: FindExceptionListOptions): Promise => { const savedObjectTypes = getSavedObjectTypes({ namespaceType }); const savedObjectsFindResponse = await savedObjectsClient.find({ filter: getExceptionListFilter({ filter, savedObjectTypes }), page, perPage, + pit, + searchAfter, sortField, sortOrder, type: savedObjectTypes, @@ -52,19 +61,3 @@ export const findExceptionList = async ({ return transformSavedObjectsToFoundExceptionList({ savedObjectsFindResponse }); }; - -export const getExceptionListFilter = ({ - filter, - savedObjectTypes, -}: { - filter: FilterOrUndefined; - savedObjectTypes: SavedObjectType[]; -}): string => { - const listTypesFilter = savedObjectTypes - .map((type) => `${type}.attributes.list_type: list`) - .join(' OR '); - - if (filter != null) { - return `(${listTypesFilter}) AND ${filter}`; - } else return `(${listTypesFilter})`; -}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts index 3d050652afed1..5c23475573bb6 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts @@ -13,6 +13,8 @@ import type { NamespaceType, PageOrUndefined, PerPageOrUndefined, + PitOrUndefined, + SearchAfterOrUndefined, SortFieldOrUndefined, SortOrderOrUndefined, } from '@kbn/securitysolution-io-ts-list-types'; @@ -24,10 +26,12 @@ interface FindExceptionListItemOptions { namespaceType: NamespaceType; savedObjectsClient: SavedObjectsClientContract; filter: FilterOrUndefined; - perPage: PerPageOrUndefined; page: PageOrUndefined; + perPage: PerPageOrUndefined; + pit: PitOrUndefined; sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; + searchAfter: SearchAfterOrUndefined; } export const findExceptionListItem = async ({ @@ -37,6 +41,8 @@ export const findExceptionListItem = async ({ filter, page, perPage, + pit, + searchAfter, sortField, sortOrder, }: FindExceptionListItemOptions): Promise => { @@ -46,7 +52,9 @@ export const findExceptionListItem = async ({ namespaceType: [namespaceType], page, perPage, + pit, savedObjectsClient, + searchAfter, sortField, sortOrder, }); diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item_point_in_time_finder.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item_point_in_time_finder.ts new file mode 100644 index 0000000000000..8b90315c80ada --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item_point_in_time_finder.ts @@ -0,0 +1,89 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/server'; +import type { + FilterOrUndefined, + FoundExceptionListItemSchema, + ListId, + MaxSizeOrUndefined, + NamespaceType, + PerPageOrUndefined, + SortFieldOrUndefined, + SortOrderOrUndefined, +} from '@kbn/securitysolution-io-ts-list-types'; + +import { findExceptionListsItemPointInTimeFinder } from './find_exception_list_items_point_in_time_finder'; + +interface FindExceptionListItemPointInTimeFinderOptions { + listId: ListId; + namespaceType: NamespaceType; + savedObjectsClient: SavedObjectsClientContract; + filter: FilterOrUndefined; + perPage: PerPageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; + executeFunctionOnStream: (response: FoundExceptionListItemSchema) => void; + maxSize: MaxSizeOrUndefined; +} + +/** + * Finds an exception list item within a point in time (PIT) and then calls the function + * `executeFunctionOnStream` until the maxPerPage is reached and stops. + * NOTE: This is slightly different from the saved objects version in that it takes + * an injected function, so that we avoid doing additional plumbing with generators + * to try to keep the maintenance of this machinery simpler for now. + * + * If you want to stream all results up to 10k into memory for correlation this would be: + * @example + * ```ts + * const exceptionList: ExceptionListItemSchema[] = []; + * const executeFunctionOnStream = (response: FoundExceptionListItemSchema) => { + * exceptionList = [...exceptionList, ...response.data]; + * } + * await client.findExceptionListItemPointInTimeFinder({ + * filter, + * executeFunctionOnStream, + * namespaceType, + * maxSize: 10_000, // NOTE: This is unbounded if it is "undefined" + * perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k + * sortField, + * sortOrder, + * exe + * }); + * ``` + * @param filter {string} Your filter + * @param namespaceType {string} "agnostic" | "single" of your namespace + * @param perPage {number} The number of items per page. Typical value should be 1_000 here. Never go above 10_000 + * @param maxSize {number of undefined} If given a max size, this will not exceeded. Otherwise if undefined is passed down, all records will be processed. + * @param sortField {string} String of the field to sort against + * @param savedObjectsClient {Object} The saved object client + * @param sortOrder "asc" | "desc" The order to sort against + */ +export const findExceptionListItemPointInTimeFinder = async ({ + executeFunctionOnStream, + listId, + namespaceType, + savedObjectsClient, + filter, + maxSize, + perPage, + sortField, + sortOrder, +}: FindExceptionListItemPointInTimeFinderOptions): Promise => { + return findExceptionListsItemPointInTimeFinder({ + executeFunctionOnStream, + filter: filter != null ? [filter] : [], + listId: [listId], + maxSize, + namespaceType: [namespaceType], + perPage, + savedObjectsClient, + sortField, + sortOrder, + }); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts deleted file mode 100644 index 3a2b12c358917..0000000000000 --- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts +++ /dev/null @@ -1,106 +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 { LIST_ID } from '../../../common/constants.mock'; - -import { getExceptionListsItemFilter } from './find_exception_list_items'; - -describe('find_exception_list_items', () => { - describe('getExceptionListsItemFilter', () => { - test('It should create a filter with a single listId with an empty filter', () => { - const filter = getExceptionListsItemFilter({ - filter: [], - listId: [LIST_ID], - savedObjectType: ['exception-list'], - }); - expect(filter).toEqual( - '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "some-list-id")' - ); - }); - - test('It should create a filter escaping quotes in list ids', () => { - const filter = getExceptionListsItemFilter({ - filter: [], - listId: ['list-id-"-with-quote'], - savedObjectType: ['exception-list'], - }); - expect(filter).toEqual( - '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-id-\\"-with-quote")' - ); - }); - - test('It should create a filter with a single listId with a single filter', () => { - const filter = getExceptionListsItemFilter({ - filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], - listId: [LIST_ID], - savedObjectType: ['exception-list'], - }); - expect(filter).toEqual( - '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "some-list-id") AND exception-list.attributes.name: "Sample Endpoint Exception List")' - ); - }); - - test('It should create a filter with 2 listIds and an empty filter', () => { - const filter = getExceptionListsItemFilter({ - filter: [], - listId: ['list-1', 'list-2'], - savedObjectType: ['exception-list', 'exception-list-agnostic'], - }); - expect(filter).toEqual( - '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2")' - ); - }); - - test('It should create a filter with 2 listIds and a single filter', () => { - const filter = getExceptionListsItemFilter({ - filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], - listId: ['list-1', 'list-2'], - savedObjectType: ['exception-list', 'exception-list-agnostic'], - }); - expect(filter).toEqual( - '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2")' - ); - }); - - test('It should create a filter with 3 listIds and an empty filter', () => { - const filter = getExceptionListsItemFilter({ - filter: [], - listId: ['list-1', 'list-2', 'list-3'], - savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], - }); - expect(filter).toEqual( - '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-3")' - ); - }); - - test('It should create a filter with 3 listIds and a single filter for the first item', () => { - const filter = getExceptionListsItemFilter({ - filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], - listId: ['list-1', 'list-2', 'list-3'], - savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], - }); - expect(filter).toEqual( - '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-3")' - ); - }); - - test('It should create a filter with 3 listIds and 3 filters for each', () => { - const filter = getExceptionListsItemFilter({ - filter: [ - 'exception-list.attributes.name: "Sample Endpoint Exception List 1"', - 'exception-list.attributes.name: "Sample Endpoint Exception List 2"', - 'exception-list.attributes.name: "Sample Endpoint Exception List 3"', - ], - listId: ['list-1', 'list-2', 'list-3'], - savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], - }); - expect(filter).toEqual( - '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") AND exception-list.attributes.name: "Sample Endpoint Exception List 1") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2") AND exception-list.attributes.name: "Sample Endpoint Exception List 2") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-3") AND exception-list.attributes.name: "Sample Endpoint Exception List 3")' - ); - }); - }); -}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts index 99298c0304c7d..5b4601dfadc22 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts @@ -5,13 +5,14 @@ * 2.0. */ -import { SavedObjectsClientContract } from 'kibana/server'; +import type { SavedObjectsClientContract } from 'kibana/server'; import type { FoundExceptionListItemSchema, - Id, NamespaceTypeArray, PageOrUndefined, PerPageOrUndefined, + PitOrUndefined, + SearchAfterOrUndefined, SortFieldOrUndefined, SortOrderOrUndefined, } from '@kbn/securitysolution-io-ts-list-types'; @@ -19,18 +20,13 @@ import type { EmptyStringArrayDecoded, NonEmptyStringArrayDecoded, } from '@kbn/securitysolution-io-ts-types'; -import { - SavedObjectType, - exceptionListAgnosticSavedObjectType, - exceptionListSavedObjectType, - getSavedObjectTypes, -} from '@kbn/securitysolution-list-utils'; +import { getSavedObjectTypes } from '@kbn/securitysolution-list-utils'; -import { escapeQuotes } from '../utils/escape_query'; -import { ExceptionListSoSchema } from '../../schemas/saved_objects'; +import type { ExceptionListSoSchema } from '../../schemas/saved_objects'; import { transformSavedObjectsToFoundExceptionListItem } from './utils'; import { getExceptionList } from './get_exception_list'; +import { getExceptionListsItemFilter } from './utils/get_exception_lists_item_filter'; interface FindExceptionListItemsOptions { listId: NonEmptyStringArrayDecoded; @@ -38,9 +34,11 @@ interface FindExceptionListItemsOptions { savedObjectsClient: SavedObjectsClientContract; filter: EmptyStringArrayDecoded; perPage: PerPageOrUndefined; + pit: PitOrUndefined; page: PageOrUndefined; sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; + searchAfter: SearchAfterOrUndefined; } export const findExceptionListsItem = async ({ @@ -49,7 +47,9 @@ export const findExceptionListsItem = async ({ savedObjectsClient, filter, page, + pit, perPage, + searchAfter, sortField, sortOrder, }: FindExceptionListItemsOptions): Promise => { @@ -73,6 +73,8 @@ export const findExceptionListsItem = async ({ filter: getExceptionListsItemFilter({ filter, listId, savedObjectType }), page, perPage, + pit, + searchAfter, sortField, sortOrder, type: savedObjectType, @@ -82,56 +84,3 @@ export const findExceptionListsItem = async ({ }); } }; - -export const getExceptionListsItemFilter = ({ - filter, - listId, - savedObjectType, -}: { - listId: NonEmptyStringArrayDecoded; - filter: EmptyStringArrayDecoded; - savedObjectType: SavedObjectType[]; -}): string => { - return listId.reduce((accum, singleListId, index) => { - const escapedListId = escapeQuotes(singleListId); - const listItemAppend = `(${savedObjectType[index]}.attributes.list_type: item AND ${savedObjectType[index]}.attributes.list_id: "${escapedListId}")`; - const listItemAppendWithFilter = - filter[index] != null ? `(${listItemAppend} AND ${filter[index]})` : listItemAppend; - if (accum === '') { - return listItemAppendWithFilter; - } else { - return `${accum} OR ${listItemAppendWithFilter}`; - } - }, ''); -}; - -interface FindValueListExceptionListsItems { - valueListId: Id; - savedObjectsClient: SavedObjectsClientContract; - perPage: PerPageOrUndefined; - page: PageOrUndefined; - sortField: SortFieldOrUndefined; - sortOrder: SortOrderOrUndefined; -} - -export const findValueListExceptionListItems = async ({ - valueListId, - savedObjectsClient, - page, - perPage, - sortField, - sortOrder, -}: FindValueListExceptionListsItems): Promise => { - const escapedValueListId = escapeQuotes(valueListId); - const savedObjectsFindResponse = await savedObjectsClient.find({ - filter: `(exception-list.attributes.list_type: item AND exception-list.attributes.entries.list.id:"${escapedValueListId}") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.entries.list.id:"${escapedValueListId}") `, - page, - perPage, - sortField, - sortOrder, - type: [exceptionListSavedObjectType, exceptionListAgnosticSavedObjectType], - }); - return transformSavedObjectsToFoundExceptionListItem({ - savedObjectsFindResponse, - }); -}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items_point_in_time_finder.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items_point_in_time_finder.ts new file mode 100644 index 0000000000000..d9020ee42b6ec --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items_point_in_time_finder.ts @@ -0,0 +1,142 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/server'; +import type { + FoundExceptionListItemSchema, + MaxSizeOrUndefined, + NamespaceTypeArray, + PerPageOrUndefined, + SortFieldOrUndefined, + SortOrderOrUndefined, +} from '@kbn/securitysolution-io-ts-list-types'; +import type { + EmptyStringArrayDecoded, + NonEmptyStringArrayDecoded, +} from '@kbn/securitysolution-io-ts-types'; +import { getSavedObjectTypes } from '@kbn/securitysolution-list-utils'; + +import type { ExceptionListSoSchema } from '../../schemas/saved_objects'; + +import { transformSavedObjectsToFoundExceptionListItem } from './utils'; +import { getExceptionList } from './get_exception_list'; +import { getExceptionListsItemFilter } from './utils/get_exception_lists_item_filter'; + +interface FindExceptionListItemsPointInTimeFinderOptions { + listId: NonEmptyStringArrayDecoded; + namespaceType: NamespaceTypeArray; + savedObjectsClient: SavedObjectsClientContract; + filter: EmptyStringArrayDecoded; + perPage: PerPageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; + executeFunctionOnStream: (response: FoundExceptionListItemSchema) => void; + maxSize: MaxSizeOrUndefined; +} + +/** + * Finds exception list items within a point in time (PIT) and then calls the function + * `executeFunctionOnStream` until the maxPerPage is reached and stops. + * NOTE: This is slightly different from the saved objects version in that it takes + * an injected function, so that we avoid doing additional plumbing with generators + * to try to keep the maintenance of this machinery simpler for now. + * + * If you want to stream all results up to 10k into memory for correlation this would be: + * @example + * ```ts + * const exceptionList: ExceptionListItemSchema[] = []; + * const executeFunctionOnStream = (response: FoundExceptionListItemSchema) => { + * exceptionList = [...exceptionList, ...response.data]; + * } + * await client.findExceptionListsItemPointInTimeFinder({ + * filter, + * executeFunctionOnStream, + * namespaceType, + * maxSize: 10_000, // NOTE: This is unbounded if it is "undefined" + * perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k + * sortField, + * sortOrder, + * exe + * }); + * ``` + * @param filter {string} Your filter + * @param namespaceType {string} "agnostic" | "single" of your namespace + * @param perPage {number} The number of items per page. Typical value should be 1_000 here. Never go above 10_000 + * @param maxSize {number of undefined} If given a max size, this will not exceeded. Otherwise if undefined is passed down, all records will be processed. + * @param sortField {string} String of the field to sort against + * @param savedObjectsClient {Object} The saved object client + * @param sortOrder "asc" | "desc" The order to sort against + */ +export const findExceptionListsItemPointInTimeFinder = async ({ + listId, + namespaceType, + savedObjectsClient, + executeFunctionOnStream, + maxSize, + filter, + perPage, + sortField, + sortOrder, +}: FindExceptionListItemsPointInTimeFinderOptions): Promise => { + const savedObjectType = getSavedObjectTypes({ namespaceType }); + const exceptionLists = ( + await Promise.all( + listId.map((singleListId, index) => { + return getExceptionList({ + id: undefined, + listId: singleListId, + namespaceType: namespaceType[index], + savedObjectsClient, + }); + }) + ) + ).filter((list) => list != null); + if (exceptionLists.length !== 0) { + const finder = savedObjectsClient.createPointInTimeFinder({ + filter: getExceptionListsItemFilter({ filter, listId, savedObjectType }), + perPage, + sortField, + sortOrder, + type: savedObjectType, + }); + + let count = 0; + for await (const savedObjectsFindResponse of finder.find()) { + count += savedObjectsFindResponse.saved_objects.length; + const exceptionListItem = transformSavedObjectsToFoundExceptionListItem({ + savedObjectsFindResponse, + }); + if (maxSize != null && count > maxSize) { + const diff = count - maxSize; + exceptionListItem.data = exceptionListItem.data.slice( + -exceptionListItem.data.length, + -diff + ); + executeFunctionOnStream(exceptionListItem); + try { + finder.close(); + } catch (exception) { + // This is just a pre-caution in case the finder does a throw we don't want to blow up + // the response. We have seen this within e2e test containers but nothing happen in normal + // operational conditions which is why this try/catch is here. + } + // early return since we are at our maxSize + return; + } else { + executeFunctionOnStream(exceptionListItem); + } + } + + try { + finder.close(); + } catch (exception) { + // This is just a pre-caution in case the finder does a throw we don't want to blow up + // the response. We have seen this within e2e test containers but nothing happen in normal + // operational conditions which is why this try/catch is here. + } + } +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_point_in_time_finder.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_point_in_time_finder.ts new file mode 100644 index 0000000000000..356735e773a01 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_point_in_time_finder.ts @@ -0,0 +1,118 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/server'; +import type { + FilterOrUndefined, + FoundExceptionListSchema, + MaxSizeOrUndefined, + NamespaceTypeArray, + PerPageOrUndefined, + SortFieldOrUndefined, + SortOrderOrUndefined, +} from '@kbn/securitysolution-io-ts-list-types'; +import { getSavedObjectTypes } from '@kbn/securitysolution-list-utils'; + +import type { ExceptionListSoSchema } from '../../schemas/saved_objects'; + +import { transformSavedObjectsToFoundExceptionList } from './utils'; +import { getExceptionListFilter } from './utils/get_exception_list_filter'; + +interface FindExceptionListPointInTimeFinderOptions { + namespaceType: NamespaceTypeArray; + savedObjectsClient: SavedObjectsClientContract; + filter: FilterOrUndefined; + perPage: PerPageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; + executeFunctionOnStream: (response: FoundExceptionListSchema) => void; + maxSize: MaxSizeOrUndefined; +} + +/** + * Finds an exception list within a point in time (PIT) and then calls the function + * `executeFunctionOnStream` until the maxPerPage is reached and stops. + * NOTE: This is slightly different from the saved objects version in that it takes + * an injected function, so that we avoid doing additional plumbing with generators + * to try to keep the maintenance of this machinery simpler for now. + * + * If you want to stream all results up to 10k into memory for correlation this would be: + * @example + * ```ts + * const exceptionList: ExceptionListSchema[] = []; + * const executeFunctionOnStream = (response: FoundExceptionListSchema) => { + * exceptionList = [...exceptionList, ...response.data]; + * } + * await client.findExceptionListPointInTimeFinder({ + * filter, + * executeFunctionOnStream, + * namespaceType, + * maxSize: 10_000, // NOTE: This is unbounded if it is "undefined" + * perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k + * sortField, + * sortOrder, + * exe + * }); + * ``` + * @param filter {string} Your filter + * @param namespaceType {string} "agnostic" | "single" of your namespace + * @param perPage {number} The number of items per page. Typical value should be 1_000 here. Never go above 10_000 + * @param maxSize {number of undefined} If given a max size, this will not exceeded. Otherwise if undefined is passed down, all records will be processed. + * @param sortField {string} String of the field to sort against + * @param sortOrder "asc" | "desc" The order to sort against + * @param savedObjectsClient The saved objects client + */ +export const findExceptionListPointInTimeFinder = async ({ + namespaceType, + savedObjectsClient, + executeFunctionOnStream, + maxSize, + filter, + perPage, + sortField, + sortOrder, +}: FindExceptionListPointInTimeFinderOptions): Promise => { + const savedObjectTypes = getSavedObjectTypes({ namespaceType }); + const finder = savedObjectsClient.createPointInTimeFinder({ + filter: getExceptionListFilter({ filter, savedObjectTypes }), + perPage, + sortField, + sortOrder, + type: savedObjectTypes, + }); + + let count = 0; + for await (const savedObjectsFindResponse of finder.find()) { + count += savedObjectsFindResponse.saved_objects.length; + const exceptionList = transformSavedObjectsToFoundExceptionList({ + savedObjectsFindResponse, + }); + if (maxSize != null && count > maxSize) { + const diff = count - maxSize; + exceptionList.data = exceptionList.data.slice(-exceptionList.data.length, -diff); + executeFunctionOnStream(exceptionList); + try { + finder.close(); + } catch (exception) { + // This is just a pre-caution in case the finder does a throw we don't want to blow up + // the response. We have seen this within e2e test containers but nothing happen in normal + // operational conditions which is why this try/catch is here. + } + // early return since we are at our maxSize + return; + } + executeFunctionOnStream(exceptionList); + } + + try { + finder.close(); + } catch (exception) { + // This is just a pre-caution in case the finder does a throw we don't want to blow up + // the response. We have seen this within e2e test containers but nothing happen in normal + // operational conditions which is why this try/catch is here. + } +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_value_list_exception_list_items.ts b/x-pack/plugins/lists/server/services/exception_lists/find_value_list_exception_list_items.ts new file mode 100644 index 0000000000000..711c8f9c253d1 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/find_value_list_exception_list_items.ts @@ -0,0 +1,64 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/server'; +import type { + FoundExceptionListItemSchema, + Id, + PageOrUndefined, + PerPageOrUndefined, + PitOrUndefined, + SearchAfterOrUndefined, + SortFieldOrUndefined, + SortOrderOrUndefined, +} from '@kbn/securitysolution-io-ts-list-types'; +import { + exceptionListAgnosticSavedObjectType, + exceptionListSavedObjectType, +} from '@kbn/securitysolution-list-utils'; + +import { escapeQuotes } from '../utils/escape_query'; +import type { ExceptionListSoSchema } from '../../schemas/saved_objects'; + +import { transformSavedObjectsToFoundExceptionListItem } from './utils'; + +interface FindValueListExceptionListsItems { + valueListId: Id; + savedObjectsClient: SavedObjectsClientContract; + perPage: PerPageOrUndefined; + pit: PitOrUndefined; + page: PageOrUndefined; + searchAfter: SearchAfterOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; +} + +export const findValueListExceptionListItems = async ({ + valueListId, + savedObjectsClient, + page, + pit, + perPage, + searchAfter, + sortField, + sortOrder, +}: FindValueListExceptionListsItems): Promise => { + const escapedValueListId = escapeQuotes(valueListId); + const savedObjectsFindResponse = await savedObjectsClient.find({ + filter: `(exception-list.attributes.list_type: item AND exception-list.attributes.entries.list.id:"${escapedValueListId}") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.entries.list.id:"${escapedValueListId}") `, + page, + perPage, + pit, + searchAfter, + sortField, + sortOrder, + type: [exceptionListSavedObjectType, exceptionListAgnosticSavedObjectType], + }); + return transformSavedObjectsToFoundExceptionListItem({ + savedObjectsFindResponse, + }); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_value_list_exception_list_items_point_in_time_finder.ts b/x-pack/plugins/lists/server/services/exception_lists/find_value_list_exception_list_items_point_in_time_finder.ts new file mode 100644 index 0000000000000..e854bd6128746 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/find_value_list_exception_list_items_point_in_time_finder.ts @@ -0,0 +1,117 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/server'; +import type { + FoundExceptionListItemSchema, + Id, + MaxSizeOrUndefined, + PerPageOrUndefined, + SortFieldOrUndefined, + SortOrderOrUndefined, +} from '@kbn/securitysolution-io-ts-list-types'; +import { + exceptionListAgnosticSavedObjectType, + exceptionListSavedObjectType, +} from '@kbn/securitysolution-list-utils'; + +import { escapeQuotes } from '../utils/escape_query'; +import type { ExceptionListSoSchema } from '../../schemas/saved_objects'; + +import { transformSavedObjectsToFoundExceptionListItem } from './utils'; + +interface FindValueListExceptionListsItemsPointInTimeFinder { + valueListId: Id; + savedObjectsClient: SavedObjectsClientContract; + perPage: PerPageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; + executeFunctionOnStream: (response: FoundExceptionListItemSchema) => void; + maxSize: MaxSizeOrUndefined; +} + +/** + * Finds value lists within exception lists within a point in time (PIT) and then calls the function + * `executeFunctionOnStream` until the maxPerPage is reached and stops. + * NOTE: This is slightly different from the saved objects version in that it takes + * an injected function, so that we avoid doing additional plumbing with generators + * to try to keep the maintenance of this machinery simpler for now. + * + * If you want to stream all results up to 10k into memory for correlation this would be: + * @example + * ```ts + * const exceptionList: ExceptionListItemSchema[] = []; + * const executeFunctionOnStream = (response: FoundExceptionListItemSchema) => { + * exceptionList = [...exceptionList, ...response.data]; + * } + * await client.findValueListExceptionListItemsPointInTimeFinder({ + * valueListId, + * executeFunctionOnStream, + * namespaceType, + * maxSize: 10_000, // NOTE: This is unbounded if it is "undefined" + * perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k + * sortField, + * sortOrder, + * exe + * }); + * ``` + * @param valueListId {string} Your value list id + * @param namespaceType {string} "agnostic" | "single" of your namespace + * @param perPage {number} The number of items per page. Typical value should be 1_000 here. Never go above 10_000 + * @param maxSize {number of undefined} If given a max size, this will not exceeded. Otherwise if undefined is passed down, all records will be processed. + * @param sortField {string} String of the field to sort against + * @param savedObjectsClient {object} The saved objects client + * @param sortOrder "asc" | "desc" The order to sort against + */ +export const findValueListExceptionListItemsPointInTimeFinder = async ({ + valueListId, + executeFunctionOnStream, + savedObjectsClient, + perPage, + maxSize, + sortField, + sortOrder, +}: FindValueListExceptionListsItemsPointInTimeFinder): Promise => { + const escapedValueListId = escapeQuotes(valueListId); + const finder = savedObjectsClient.createPointInTimeFinder({ + filter: `(exception-list.attributes.list_type: item AND exception-list.attributes.entries.list.id:"${escapedValueListId}") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.entries.list.id:"${escapedValueListId}") `, + perPage, + sortField, + sortOrder, + type: [exceptionListSavedObjectType, exceptionListAgnosticSavedObjectType], + }); + let count = 0; + for await (const savedObjectsFindResponse of finder.find()) { + count += savedObjectsFindResponse.saved_objects.length; + const exceptionList = transformSavedObjectsToFoundExceptionListItem({ + savedObjectsFindResponse, + }); + if (maxSize != null && count > maxSize) { + const diff = count - maxSize; + exceptionList.data = exceptionList.data.slice(-exceptionList.data.length, -diff); + executeFunctionOnStream(exceptionList); + try { + finder.close(); + } catch (exception) { + // This is just a pre-caution in case the finder does a throw we don't want to blow up + // the response. We have seen this within e2e test containers but nothing happen in normal + // operational conditions which is why this try/catch is here. + } + // early return since we are at our maxSize + return; + } + executeFunctionOnStream(exceptionList); + } + + try { + finder.close(); + } catch (exception) { + // This is just a pre-caution in case the finder does a throw we don't want to blow up + // the response. We have seen this within e2e test containers but nothing happen in normal + // operational conditions which is why this try/catch is here. + } +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/open_point_in_time.ts b/x-pack/plugins/lists/server/services/exception_lists/open_point_in_time.ts new file mode 100644 index 0000000000000..271676527dd2f --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/open_point_in_time.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 { + SavedObjectsClientContract, + SavedObjectsOpenPointInTimeOptions, + SavedObjectsOpenPointInTimeResponse, +} from 'kibana/server'; +import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; +import { getSavedObjectTypes } from '@kbn/securitysolution-list-utils'; + +interface OpenPointInTimeOptions { + namespaceType: NamespaceType; + savedObjectsClient: SavedObjectsClientContract; + options: SavedObjectsOpenPointInTimeOptions | undefined; +} + +/** + * Opens a point in time (PIT) for either exception lists or exception list items. + * See: https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * @params namespaceType {string} "agnostic" or "single" depending on which namespace you are targeting + * @params savedObjectsClient {object} The saved object client to delegate to + * @params options {Object} The saved object PIT options + * @return {SavedObjectsOpenPointInTimeResponse} The point in time (PIT) + */ +export const openPointInTime = async ({ + namespaceType, + savedObjectsClient, + options, +}: OpenPointInTimeOptions): Promise => { + const savedObjectType = getSavedObjectTypes({ namespaceType: [namespaceType] }); + return savedObjectsClient.openPointInTimeForType(savedObjectType, options); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_list_filter.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_list_filter.test.ts new file mode 100644 index 0000000000000..9e0b06f8482c7 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_list_filter.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { getExceptionListFilter } from './get_exception_list_filter'; + +describe('getExceptionListFilter', () => { + test('it should create a filter for agnostic lists if only searching for agnostic lists', () => { + const filter = getExceptionListFilter({ + filter: undefined, + savedObjectTypes: ['exception-list-agnostic'], + }); + expect(filter).toEqual('(exception-list-agnostic.attributes.list_type: list)'); + }); + + test('it should create a filter for agnostic lists with additional filters if only searching for agnostic lists', () => { + const filter = getExceptionListFilter({ + filter: 'exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"', + savedObjectTypes: ['exception-list-agnostic'], + }); + expect(filter).toEqual( + '(exception-list-agnostic.attributes.list_type: list) AND exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"' + ); + }); + + test('it should create a filter for single lists if only searching for single lists', () => { + const filter = getExceptionListFilter({ + filter: undefined, + savedObjectTypes: ['exception-list'], + }); + expect(filter).toEqual('(exception-list.attributes.list_type: list)'); + }); + + test('it should create a filter for single lists with additional filters if only searching for single lists', () => { + const filter = getExceptionListFilter({ + filter: 'exception-list.attributes.name: "Sample Endpoint Exception List"', + savedObjectTypes: ['exception-list'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: list) AND exception-list.attributes.name: "Sample Endpoint Exception List"' + ); + }); + + test('it should create a filter that searches for both agnostic and single lists', () => { + const filter = getExceptionListFilter({ + filter: undefined, + savedObjectTypes: ['exception-list-agnostic', 'exception-list'], + }); + expect(filter).toEqual( + '(exception-list-agnostic.attributes.list_type: list OR exception-list.attributes.list_type: list)' + ); + }); + + test('it should create a filter that searches for both agnostic and single lists with additional filters if searching for both single and agnostic lists', () => { + const filter = getExceptionListFilter({ + filter: 'exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"', + savedObjectTypes: ['exception-list-agnostic', 'exception-list'], + }); + expect(filter).toEqual( + '(exception-list-agnostic.attributes.list_type: list OR exception-list.attributes.list_type: list) AND exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"' + ); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_list_filter.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_list_filter.ts new file mode 100644 index 0000000000000..44a9be320755f --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_list_filter.ts @@ -0,0 +1,25 @@ +/* + * 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 { FilterOrUndefined } from '@kbn/securitysolution-io-ts-list-types'; +import type { SavedObjectType } from '@kbn/securitysolution-list-utils'; + +export const getExceptionListFilter = ({ + filter, + savedObjectTypes, +}: { + filter: FilterOrUndefined; + savedObjectTypes: SavedObjectType[]; +}): string => { + const listTypesFilter = savedObjectTypes + .map((type) => `${type}.attributes.list_type: list`) + .join(' OR '); + + if (filter != null) { + return `(${listTypesFilter}) AND ${filter}`; + } else return `(${listTypesFilter})`; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.test.ts new file mode 100644 index 0000000000000..8e8b3499338dc --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.test.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LIST_ID } from '../../../../common/constants.mock'; + +import { getExceptionListsItemFilter } from './get_exception_lists_item_filter'; + +describe('getExceptionListsItemFilter', () => { + test('It should create a filter with a single listId with an empty filter', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: [LIST_ID], + savedObjectType: ['exception-list'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "some-list-id")' + ); + }); + + test('It should create a filter escaping quotes in list ids', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: ['list-id-"-with-quote'], + savedObjectType: ['exception-list'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-id-\\"-with-quote")' + ); + }); + + test('It should create a filter with a single listId with a single filter', () => { + const filter = getExceptionListsItemFilter({ + filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], + listId: [LIST_ID], + savedObjectType: ['exception-list'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "some-list-id") AND exception-list.attributes.name: "Sample Endpoint Exception List")' + ); + }); + + test('It should create a filter with 2 listIds and an empty filter', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: ['list-1', 'list-2'], + savedObjectType: ['exception-list', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2")' + ); + }); + + test('It should create a filter with 2 listIds and a single filter', () => { + const filter = getExceptionListsItemFilter({ + filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], + listId: ['list-1', 'list-2'], + savedObjectType: ['exception-list', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2")' + ); + }); + + test('It should create a filter with 3 listIds and an empty filter', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: ['list-1', 'list-2', 'list-3'], + savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-3")' + ); + }); + + test('It should create a filter with 3 listIds and a single filter for the first item', () => { + const filter = getExceptionListsItemFilter({ + filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], + listId: ['list-1', 'list-2', 'list-3'], + savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-3")' + ); + }); + + test('It should create a filter with 3 listIds and 3 filters for each', () => { + const filter = getExceptionListsItemFilter({ + filter: [ + 'exception-list.attributes.name: "Sample Endpoint Exception List 1"', + 'exception-list.attributes.name: "Sample Endpoint Exception List 2"', + 'exception-list.attributes.name: "Sample Endpoint Exception List 3"', + ], + listId: ['list-1', 'list-2', 'list-3'], + savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") AND exception-list.attributes.name: "Sample Endpoint Exception List 1") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2") AND exception-list.attributes.name: "Sample Endpoint Exception List 2") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-3") AND exception-list.attributes.name: "Sample Endpoint Exception List 3")' + ); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.ts new file mode 100644 index 0000000000000..935ae8839a71d --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + EmptyStringArrayDecoded, + NonEmptyStringArrayDecoded, +} from '@kbn/securitysolution-io-ts-types'; +import type { SavedObjectType } from '@kbn/securitysolution-list-utils'; + +import { escapeQuotes } from '../../utils/escape_query'; + +export const getExceptionListsItemFilter = ({ + filter, + listId, + savedObjectType, +}: { + listId: NonEmptyStringArrayDecoded; + filter: EmptyStringArrayDecoded; + savedObjectType: SavedObjectType[]; +}): string => { + return listId.reduce((accum, singleListId, index) => { + const escapedListId = escapeQuotes(singleListId); + const listItemAppend = `(${savedObjectType[index]}.attributes.list_type: item AND ${savedObjectType[index]}.attributes.list_id: "${escapedListId}")`; + const listItemAppendWithFilter = + filter[index] != null ? `(${listItemAppend} AND ${filter[index]})` : listItemAppend; + if (accum === '') { + return listItemAppendWithFilter; + } else { + return `${accum} OR ${listItemAppendWithFilter}`; + } + }, ''); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.ts index 272c64f161c9c..3884d12b4bb6f 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.ts @@ -14,7 +14,7 @@ import { getSavedObjectTypes } from '@kbn/securitysolution-list-utils'; import { SavedObjectsClientContract, SavedObjectsFindResponse } from 'kibana/server'; import { ExceptionListSoSchema } from '../../../../schemas/saved_objects'; -import { getExceptionListsItemFilter } from '../../find_exception_list_items'; +import { getExceptionListsItemFilter } from '../get_exception_lists_item_filter'; import { CHUNK_PARSED_OBJECT_SIZE } from '../../import_exception_list_and_items'; import { transformSavedObjectsToFoundExceptionListItem } from '..'; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_types.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_types.ts index 4b42787d8aaf9..51538faa66942 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_types.ts @@ -72,7 +72,9 @@ export const findAllListTypes = async ( namespaceType: ['agnostic'], page: undefined, perPage: CHUNK_PARSED_OBJECT_SIZE, + pit: undefined, savedObjectsClient, + searchAfter: undefined, sortField: undefined, sortOrder: undefined, }); @@ -82,7 +84,9 @@ export const findAllListTypes = async ( namespaceType: ['single'], page: undefined, perPage: CHUNK_PARSED_OBJECT_SIZE, + pit: undefined, savedObjectsClient, + searchAfter: undefined, sortField: undefined, sortOrder: undefined, }); @@ -92,7 +96,9 @@ export const findAllListTypes = async ( namespaceType: ['single', 'agnostic'], page: undefined, perPage: CHUNK_PARSED_OBJECT_SIZE, + pit: undefined, savedObjectsClient, + searchAfter: undefined, sortField: undefined, sortOrder: undefined, }); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/index.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/index.ts index ae1883f5767e5..b0b0151e5f537 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/index.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/index.ts @@ -247,6 +247,7 @@ export const transformSavedObjectsToFoundExceptionListItem = ({ ), page: savedObjectsFindResponse.page, per_page: savedObjectsFindResponse.per_page, + pit: savedObjectsFindResponse.pit_id, total: savedObjectsFindResponse.total, }; }; @@ -262,6 +263,7 @@ export const transformSavedObjectsToFoundExceptionList = ({ ), page: savedObjectsFindResponse.page, per_page: savedObjectsFindResponse.per_page, + pit: savedObjectsFindResponse.pit_id, total: savedObjectsFindResponse.total, }; }; diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts index 2fb713526fce8..b0cdc77724ac7 100644 --- a/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts +++ b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts @@ -30,6 +30,9 @@ export const getListItemByValues = async ({ type, value, }: GetListItemByValuesOptions): Promise => { + // TODO: Will need to address this when we switch over to + // using PIT, don't want it to get lost + // https://github.com/elastic/kibana/issues/103944 const response = await esClient.search({ body: { query: { diff --git a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts index fb81594137861..2a39f863311b9 100644 --- a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts +++ b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts @@ -30,6 +30,9 @@ export const searchListItemByValues = async ({ type, value, }: SearchListItemByValuesOptions): Promise => { + // TODO: Will need to address this when we switch over to + // using PIT, don't want it to get lost + // https://github.com/elastic/kibana/issues/103944 const response = await esClient.search({ body: { query: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index a1b0548a81d25..c57a446207873 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -7,7 +7,7 @@ import moment from 'moment'; import sinon from 'sinon'; -import { TransportResult } from '@elastic/elasticsearch'; +import type { TransportResult } from '@elastic/elasticsearch'; import { ALERT_REASON, ALERT_RULE_PARAMETERS, ALERT_UUID } from '@kbn/rule-data-utils'; import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; @@ -46,7 +46,7 @@ import { isRACAlert, getField, } from './utils'; -import { BulkResponseErrorAggregation, SearchAfterAndBulkCreateReturnType } from './types'; +import type { BulkResponseErrorAggregation, SearchAfterAndBulkCreateReturnType } from './types'; import { sampleBulkResponse, sampleEmptyBulkResponse, @@ -62,7 +62,7 @@ import { sampleAlertDocNoSortIdWithTimestamp, sampleAlertDocAADNoSortIdWithTimestamp, } from './__mocks__/es_results'; -import { ShardError } from '../../types'; +import type { ShardError } from '../../types'; import { ruleExecutionLogMock } from '../rule_execution_log/__mocks__'; const buildRuleMessage = buildRuleMessageFactory({ @@ -568,12 +568,11 @@ describe('utils', () => { test('it successfully returns array of exception list items', async () => { listMock.getExceptionListClient = () => ({ - findExceptionListsItem: jest.fn().mockResolvedValue({ - data: [getExceptionListItemSchemaMock()], - page: 1, - per_page: 10000, - total: 1, - }), + findExceptionListsItemPointInTimeFinder: jest + .fn() + .mockImplementationOnce(({ executeFunctionOnStream }) => { + executeFunctionOnStream({ data: [getExceptionListItemSchemaMock()] }); + }), } as unknown as ExceptionListClient); const client = listMock.getExceptionListClient(); const exceptions = await getExceptions({ @@ -581,38 +580,25 @@ describe('utils', () => { lists: getListArrayMock(), }); - expect(client.findExceptionListsItem).toHaveBeenCalledWith({ - listId: ['list_id_single', 'endpoint_list'], - namespaceType: ['single', 'agnostic'], - page: 1, - perPage: 10000, - filter: [], - sortOrder: undefined, - sortField: undefined, - }); - expect(exceptions).toEqual([getExceptionListItemSchemaMock()]); - }); - - test('it throws if "getExceptionListClient" fails', async () => { - const err = new Error('error fetching list'); - listMock.getExceptionListClient = () => - ({ - getExceptionList: jest.fn().mockRejectedValue(err), - } as unknown as ExceptionListClient); - - await expect(() => - getExceptions({ - client: listMock.getExceptionListClient(), - lists: getListArrayMock(), + expect(client.findExceptionListsItemPointInTimeFinder).toHaveBeenCalledWith( + expect.objectContaining({ + listId: ['list_id_single', 'endpoint_list'], + namespaceType: ['single', 'agnostic'], + perPage: 1_000, + filter: [], + maxSize: undefined, + sortOrder: undefined, + sortField: undefined, }) - ).rejects.toThrowError('unable to fetch exception list items'); + ); + expect(exceptions).toEqual([getExceptionListItemSchemaMock()]); }); - test('it throws if "findExceptionListsItem" fails', async () => { + test('it throws if "findExceptionListsItemPointInTimeFinder" fails anywhere', async () => { const err = new Error('error fetching list'); listMock.getExceptionListClient = () => ({ - findExceptionListsItem: jest.fn().mockRejectedValue(err), + findExceptionListsItemPointInTimeFinder: jest.fn().mockRejectedValue(err), } as unknown as ExceptionListClient); await expect(() => @@ -620,7 +606,9 @@ describe('utils', () => { client: listMock.getExceptionListClient(), lists: getListArrayMock(), }) - ).rejects.toThrowError('unable to fetch exception list items'); + ).rejects.toThrowError( + 'unable to fetch exception list items, message: "error fetching list" full error: "Error: error fetching list"' + ); }); test('it returns empty array if "findExceptionListsItem" returns null', async () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 31e43c694084a..cb1db88f78d31 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -11,10 +11,13 @@ import uuidv5 from 'uuid/v5'; import dateMath from '@elastic/datemath'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { TransportResult } from '@elastic/elasticsearch'; +import type { TransportResult } from '@elastic/elasticsearch'; import { ALERT_UUID, ALERT_RULE_UUID, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils'; -import type { ListArray, ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { MAX_EXCEPTION_LIST_SIZE } from '@kbn/securitysolution-list-constants'; +import type { + ListArray, + ExceptionListItemSchema, + FoundExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { hasLargeValueList } from '@kbn/securitysolution-list-utils'; import { @@ -22,7 +25,7 @@ import { Privilege, RuleExecutionStatus, } from '../../../../common/detection_engine/schemas/common'; -import { +import type { ElasticsearchClient, Logger, SavedObjectsClientContract, @@ -33,8 +36,8 @@ import { AlertServices, parseDuration, } from '../../../../../alerting/server'; -import { ExceptionListClient, ListClient, ListPluginSetup } from '../../../../../lists/server'; -import { +import type { ExceptionListClient, ListClient, ListPluginSetup } from '../../../../../lists/server'; +import type { BulkResponseErrorAggregation, SignalHit, SearchAfterAndBulkCreateReturnType, @@ -47,9 +50,9 @@ import { SimpleHit, WrappedEventHit, } from './types'; -import { BuildRuleMessage } from './rule_messages'; -import { ShardError } from '../../types'; -import { +import type { BuildRuleMessage } from './rule_messages'; +import type { ShardError } from '../../types'; +import type { EqlRuleParams, MachineLearningRuleParams, QueryRuleParams, @@ -58,9 +61,9 @@ import { ThreatRuleParams, ThresholdRuleParams, } from '../schemas/rule_schemas'; -import { RACAlert, WrappedRACAlert } from '../rule_types/types'; -import { SearchTypes } from '../../../../common/detection_engine/types'; -import { IRuleExecutionLogForExecutors } from '../rule_execution_log'; +import type { RACAlert, WrappedRACAlert } from '../rule_types/types'; +import type { SearchTypes } from '../../../../common/detection_engine/types'; +import type { IRuleExecutionLogForExecutors } from '../rule_execution_log'; import { withSecuritySpan } from '../../../utils/with_security_span'; interface SortExceptionsReturn { @@ -269,18 +272,28 @@ export const getExceptions = async ({ try { const listIds = lists.map(({ list_id: listId }) => listId); const namespaceTypes = lists.map(({ namespace_type: namespaceType }) => namespaceType); - const items = await client.findExceptionListsItem({ + + // Stream the results from the Point In Time (PIT) finder into this array + let items: ExceptionListItemSchema[] = []; + const executeFunctionOnStream = (response: FoundExceptionListItemSchema): void => { + items = [...items, ...response.data]; + }; + + await client.findExceptionListsItemPointInTimeFinder({ + executeFunctionOnStream, listId: listIds, namespaceType: namespaceTypes, - page: 1, - perPage: MAX_EXCEPTION_LIST_SIZE, + perPage: 1_000, // See https://github.com/elastic/kibana/issues/93770 for choice of 1k filter: [], + maxSize: undefined, // NOTE: This is unbounded when it is "undefined" sortOrder: undefined, sortField: undefined, }); - return items != null ? items.data : []; - } catch { - throw new Error('unable to fetch exception list items'); + return items; + } catch (e) { + throw new Error( + `unable to fetch exception list items, message: "${e.message}" full error: "${e}"` + ); } } else { return []; From cf102be8befdd3488377b7c7f8cf9545abf45721 Mon Sep 17 00:00:00 2001 From: Max Kovalev Date: Wed, 16 Feb 2022 01:52:38 +0200 Subject: [PATCH 35/39] #121908 - [Maps] Use UI counters to instrument when a geo field is visualized with visualizeGeoFieldAction (#123540) * #121908 - Maps usage tracking feature * #e-121908 - refactoring; Added naming constants for used apps; * #121908 - updates for UiCounterMetricType, schema, metric analytics * #121908 - refactoring; Fixed import syntax, type in report * #121908 - refactoring * #121908 - refactoring * 121908 - refactoring Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../kbn-analytics/src/metrics/ui_counter.ts | 10 ++-- packages/kbn-analytics/src/report.ts | 52 ++++++++----------- packages/kbn-analytics/src/reporter.ts | 10 +--- src/plugins/discover/common/index.ts | 1 + .../sidebar/lib/visualize_trigger_utils.ts | 2 + src/plugins/ui_actions/public/types.ts | 1 + .../usage_collection/server/report/schema.ts | 7 +-- .../data_visualizer/common/constants.ts | 1 + .../field_data_row/action_menu/actions.ts | 2 + .../geo_field_workspace_panel.tsx | 2 + .../visualize_geo_field_button.tsx | 2 + x-pack/plugins/maps/public/kibana_services.ts | 1 + x-pack/plugins/maps/public/plugin.ts | 1 + .../visualize_geo_field_action.ts | 10 ++++ 14 files changed, 56 insertions(+), 46 deletions(-) diff --git a/packages/kbn-analytics/src/metrics/ui_counter.ts b/packages/kbn-analytics/src/metrics/ui_counter.ts index 188e5d6ea8a9d..f2a5d2fd18d12 100644 --- a/packages/kbn-analytics/src/metrics/ui_counter.ts +++ b/packages/kbn-analytics/src/metrics/ui_counter.ts @@ -8,16 +8,20 @@ import { METRIC_TYPE } from './'; -export type UiCounterMetricType = METRIC_TYPE.CLICK | METRIC_TYPE.LOADED | METRIC_TYPE.COUNT; +export type UiCounterMetricType = + | METRIC_TYPE.CLICK + | METRIC_TYPE.LOADED + | METRIC_TYPE.COUNT + | string; export interface UiCounterMetricConfig { - type: UiCounterMetricType; + type: string; appName: string; eventName: string; count?: number; } export interface UiCounterMetric { - type: UiCounterMetricType; + type: string; appName: string; eventName: string; count: number; diff --git a/packages/kbn-analytics/src/report.ts b/packages/kbn-analytics/src/report.ts index 16bc72a212550..9894d1165789c 100644 --- a/packages/kbn-analytics/src/report.ts +++ b/packages/kbn-analytics/src/report.ts @@ -7,11 +7,15 @@ */ import moment from 'moment-timezone'; -import { UnreachableCaseError, wrapArray } from './util'; +import { wrapArray } from './util'; import { ApplicationUsageTracker } from './application_usage_tracker'; -import { Metric, UiCounterMetricType, METRIC_TYPE } from './metrics'; +import { Metric, METRIC_TYPE } from './metrics'; const REPORT_VERSION = 3; +import type { UiCounterMetric, UiCounterMetricType } from './metrics/ui_counter'; +import type { UserAgentMetric } from './metrics/user_agent'; +import type { ApplicationUsageMetric } from './metrics/application_usage'; + export interface Report { reportVersion: typeof REPORT_VERSION; uiCounter?: Record< @@ -77,55 +81,35 @@ export class ReportManager { const { appName, type } = metric; return `${appName}-${type}`; } - case METRIC_TYPE.CLICK: - case METRIC_TYPE.LOADED: - case METRIC_TYPE.COUNT: { - const { appName, eventName, type } = metric; - return `${appName}-${type}-${eventName}`; - } case METRIC_TYPE.APPLICATION_USAGE: { - const { appId, viewId } = metric; + const { appId, viewId } = metric as ApplicationUsageMetric; return ApplicationUsageTracker.serializeKey({ appId, viewId }); } default: - throw new UnreachableCaseError(metric); + const { appName, eventName, type } = metric as UiCounterMetric; + return `${appName}-${type}-${eventName}`; } } private assignReport(report: Report, metric: Metric) { const key = ReportManager.createMetricKey(metric); switch (metric.type) { case METRIC_TYPE.USER_AGENT: { - const { appName, type, userAgent } = metric; + const { appName, type, userAgent } = metric as UserAgentMetric; if (userAgent) { report.userAgent = { [key]: { key, appName, type, - userAgent: metric.userAgent, + userAgent, }, }; } return; } - case METRIC_TYPE.CLICK: - case METRIC_TYPE.LOADED: - case METRIC_TYPE.COUNT: { - const { appName, type, eventName, count } = metric; - report.uiCounter = report.uiCounter || {}; - const currentTotal = report.uiCounter[key]?.total; - report.uiCounter[key] = { - key, - appName, - eventName, - type, - total: this.incrementTotal(count, currentTotal), - }; - return; - } case METRIC_TYPE.APPLICATION_USAGE: { - const { numberOfClicks, startTime, appId, viewId } = metric; + const { numberOfClicks, startTime, appId, viewId } = metric as ApplicationUsageMetric; const minutesOnScreen = moment().diff(startTime, 'minutes', true); report.application_usage = report.application_usage || {}; @@ -144,7 +128,17 @@ export class ReportManager { return; } default: - throw new UnreachableCaseError(metric); + const { appName, type, eventName, count } = metric as UiCounterMetric; + report.uiCounter = report.uiCounter || {}; + const currentTotal = report.uiCounter[key]?.total; + report.uiCounter[key] = { + key, + appName, + eventName, + type, + total: this.incrementTotal(count, currentTotal), + }; + return; } } } diff --git a/packages/kbn-analytics/src/reporter.ts b/packages/kbn-analytics/src/reporter.ts index c7c9cf1541d21..93ef2c91d2589 100644 --- a/packages/kbn-analytics/src/reporter.ts +++ b/packages/kbn-analytics/src/reporter.ts @@ -7,13 +7,7 @@ */ import { wrapArray } from './util'; -import { - Metric, - createUiCounterMetric, - trackUsageAgent, - UiCounterMetricType, - ApplicationUsageMetric, -} from './metrics'; +import { Metric, createUiCounterMetric, trackUsageAgent, ApplicationUsageMetric } from './metrics'; import { Storage, ReportStorageManager } from './storage'; import { Report, ReportManager } from './report'; @@ -77,7 +71,7 @@ export class Reporter { public reportUiCounter = ( appName: string, - type: UiCounterMetricType, + type: string, eventNames: string | string[], count?: number ) => { diff --git a/src/plugins/discover/common/index.ts b/src/plugins/discover/common/index.ts index 98ce5fc3b0b2b..f2a9e53fc067c 100644 --- a/src/plugins/discover/common/index.ts +++ b/src/plugins/discover/common/index.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +export const APP_ID = 'discover'; export const DEFAULT_COLUMNS_SETTING = 'defaultColumns'; export const SAMPLE_SIZE_SETTING = 'discover:sampleSize'; export const SORT_DEFAULT_ORDER_SETTING = 'discover:sort:defaultOrder'; diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/visualize_trigger_utils.ts b/src/plugins/discover/public/application/main/components/sidebar/lib/visualize_trigger_utils.ts index 365230823a6a5..275b919d5e058 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/lib/visualize_trigger_utils.ts +++ b/src/plugins/discover/public/application/main/components/sidebar/lib/visualize_trigger_utils.ts @@ -14,6 +14,7 @@ import { } from '../../../../../../../ui_actions/public'; import { getUiActions } from '../../../../../kibana_services'; import { DataViewField, KBN_FIELD_TYPES } from '../../../../../../../data/common'; +import { APP_ID } from '../../../../../../common'; function getTriggerConstant(type: string) { return type === KBN_FIELD_TYPES.GEO_POINT || type === KBN_FIELD_TYPES.GEO_SHAPE @@ -52,6 +53,7 @@ export function triggerVisualizeActions( indexPatternId, fieldName: field.name, contextualFields, + originatingApp: APP_ID, }; getUiActions().getTrigger(trigger).exec(triggerOptions); } diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index 59cc001c41211..0035bdc426a02 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -17,6 +17,7 @@ export interface VisualizeFieldContext { fieldName: string; indexPatternId: string; contextualFields?: string[]; + originatingApp?: string; } export const ACTION_VISUALIZE_FIELD = 'ACTION_VISUALIZE_FIELD'; diff --git a/src/plugins/usage_collection/server/report/schema.ts b/src/plugins/usage_collection/server/report/schema.ts index 350ec8d90e765..1f76d3a4db76d 100644 --- a/src/plugins/usage_collection/server/report/schema.ts +++ b/src/plugins/usage_collection/server/report/schema.ts @@ -7,7 +7,6 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; -import { METRIC_TYPE } from '@kbn/analytics'; const applicationUsageReportSchema = schema.object({ minutesOnScreen: schema.number(), @@ -34,11 +33,7 @@ export const reportSchema = schema.object({ schema.string(), schema.object({ key: schema.string(), - type: schema.oneOf([ - schema.literal(METRIC_TYPE.CLICK), - schema.literal(METRIC_TYPE.LOADED), - schema.literal(METRIC_TYPE.COUNT), - ]), + type: schema.string(), appName: schema.string(), eventName: schema.string(), total: schema.number(), diff --git a/x-pack/plugins/data_visualizer/common/constants.ts b/x-pack/plugins/data_visualizer/common/constants.ts index ab25ecff4045d..55b37f9667e57 100644 --- a/x-pack/plugins/data_visualizer/common/constants.ts +++ b/x-pack/plugins/data_visualizer/common/constants.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import { KBN_FIELD_TYPES } from '../../../../src/plugins/data/common'; +export const APP_ID = 'data_visualizer'; export const UI_SETTING_MAX_FILE_SIZE = 'fileUpload:maxFileSize'; export const MB = Math.pow(2, 20); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts index 34023691307d0..4b99cf3ca74f9 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts @@ -19,6 +19,7 @@ import { } from '../../../../index_data_visualizer/services/timefilter_refresh_service'; import { JOB_FIELD_TYPES } from '../../../../../../common/constants'; import { VISUALIZE_GEO_FIELD_TRIGGER } from '../../../../../../../../../src/plugins/ui_actions/public'; +import { APP_ID } from '../../../../../../common/constants'; export function getActions( indexPattern: IndexPattern, @@ -87,6 +88,7 @@ export function getActions( indexPatternId: indexPattern.id, fieldName: item.fieldName, contextualFields: [], + originatingApp: APP_ID, }; const testActions = await services?.uiActions.getTriggerCompatibleActions( VISUALIZE_GEO_FIELD_TRIGGER, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/geo_field_workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/geo_field_workspace_panel.tsx index 0d523747c5901..2ed4f223994c6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/geo_field_workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/geo_field_workspace_panel.tsx @@ -16,6 +16,7 @@ import { import { getVisualizeGeoFieldMessage } from '../../../utils'; import { DragDrop } from '../../../drag_drop'; import { GlobeIllustration } from '../../../assets/globe_illustration'; +import { APP_ID } from '../../../../common/constants'; import './geo_field_workspace_panel.scss'; interface Props { @@ -41,6 +42,7 @@ export function GeoFieldWorkspacePanel(props: Props) { props.uiActions.getTrigger(VISUALIZE_GEO_FIELD_TRIGGER).exec({ indexPatternId: props.indexPatternId, fieldName: props.fieldName, + originatingApp: APP_ID, }); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/visualize_geo_field_button.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/visualize_geo_field_button.tsx index 7b0a64a9b9233..6a23539f2650f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/visualize_geo_field_button.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/visualize_geo_field_button.tsx @@ -13,6 +13,7 @@ import { VISUALIZE_GEO_FIELD_TRIGGER, UiActionsStart, } from '../../../../../src/plugins/ui_actions/public'; +import { APP_ID } from '../../common/constants'; interface Props { indexPatternId: string; @@ -50,6 +51,7 @@ export function VisualizeGeoFieldButton(props: Props) { props.uiActions.getTrigger(VISUALIZE_GEO_FIELD_TRIGGER).exec({ indexPatternId: props.indexPatternId, fieldName: props.fieldName, + originatingApp: APP_ID, }); } diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index 027981de32295..54e4964b35f03 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -55,6 +55,7 @@ export const getPresentationUtilContext = () => pluginsStart.presentationUtil.Co export const getSecurityService = () => pluginsStart.security; export const getSpacesApi = () => pluginsStart.spaces; export const getTheme = () => coreStart.theme; +export const getUsageCollection = () => pluginsStart.usageCollection; // xpack.maps.* kibana.yml settings from this plugin let mapAppConfig: MapsConfigType; diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index b11d7270fe13e..69d973dd4427b 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -103,6 +103,7 @@ export interface MapsPluginStartDependencies { security?: SecurityPluginStart; spaces?: SpacesPluginStart; mapsEms: MapsEmsPluginPublicStart; + usageCollection?: UsageCollectionSetup; } /** diff --git a/x-pack/plugins/maps/public/trigger_actions/visualize_geo_field_action.ts b/x-pack/plugins/maps/public/trigger_actions/visualize_geo_field_action.ts index 4f99039742885..c148770b913ea 100644 --- a/x-pack/plugins/maps/public/trigger_actions/visualize_geo_field_action.ts +++ b/x-pack/plugins/maps/public/trigger_actions/visualize_geo_field_action.ts @@ -8,6 +8,9 @@ import uuid from 'uuid/v4'; import { i18n } from '@kbn/i18n'; import type { SerializableRecord } from '@kbn/utility-types'; +import { getUsageCollection } from '../kibana_services'; +import { APP_ID } from '../../common/constants'; + import { createAction, ACTION_VISUALIZE_GEO_FIELD, @@ -43,6 +46,13 @@ export const visualizeGeoFieldAction = createAction({ execute: async (context) => { const { app, path, state } = await getMapsLink(context); + const usageCollection = getUsageCollection(); + usageCollection?.reportUiCounter( + APP_ID, + 'visualize_geo_field', + context.originatingApp ? context.originatingApp : 'unknownOriginatingApp' + ); + getCore().application.navigateToApp(app, { path, state, From c4c4eeeb28593ae8786b6970257b3b319157117c Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Tue, 15 Feb 2022 18:37:49 -0700 Subject: [PATCH 36/39] Adds nav link to uisettings tutorial (#125736) --- nav-kibana-dev.docnav.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index fae6a0ea99ccd..5df9961f28103 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -46,7 +46,8 @@ "id": "kibDevTutorialBuildingDistributable", "label": "Building a Kibana distributable" }, - { "id": "kibDevTutorialServerEndpoint" } + { "id": "kibDevTutorialServerEndpoint" }, + { "id": "kibDevTutorialAdvancedSettings"} ] }, { From e9d82d104b569e6b7ea72e2d2fe79121785aba6c Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 15 Feb 2022 18:06:13 -0800 Subject: [PATCH 37/39] Updates to Jest configuration (#125727) --- .buildkite/pipelines/es_snapshots/verify.yml | 1 + .../scripts/steps/code_coverage/jest.sh | 4 +-- .buildkite/scripts/steps/test/jest.sh | 2 +- .../scripts/steps/test/jest_parallel.sh | 2 +- jest.config.integration.js | 27 ------------------- jest.config.js | 24 ----------------- .../src/jest/run_check_jest_configs_cli.ts | 4 +-- scripts/jest_integration.js | 2 +- 8 files changed, 8 insertions(+), 58 deletions(-) delete mode 100644 jest.config.integration.js delete mode 100644 jest.config.js diff --git a/.buildkite/pipelines/es_snapshots/verify.yml b/.buildkite/pipelines/es_snapshots/verify.yml index 7d700b1e0f489..f98626ef25c01 100755 --- a/.buildkite/pipelines/es_snapshots/verify.yml +++ b/.buildkite/pipelines/es_snapshots/verify.yml @@ -65,6 +65,7 @@ steps: - command: .buildkite/scripts/steps/test/jest_integration.sh label: 'Jest Integration Tests' + parallelism: 2 agents: queue: n2-4 timeout_in_minutes: 120 diff --git a/.buildkite/scripts/steps/code_coverage/jest.sh b/.buildkite/scripts/steps/code_coverage/jest.sh index 0884a27c03aad..2e2284e498754 100755 --- a/.buildkite/scripts/steps/code_coverage/jest.sh +++ b/.buildkite/scripts/steps/code_coverage/jest.sh @@ -10,11 +10,11 @@ is_test_execution_step echo '--- Jest code coverage' -.buildkite/scripts/steps/code_coverage/jest_parallel.sh +.buildkite/scripts/steps/code_coverage/jest_parallel.sh jest.config.js tar -czf kibana-jest-thread-coverage.tar.gz target/kibana-coverage/jest echo "--- Merging code coverage for a thread" yarn nyc report --nycrc-path src/dev/code_coverage/nyc_config/nyc.jest.config.js --reporter json rm -rf target/kibana-coverage/jest/* -mv target/kibana-coverage/jest-combined/coverage-final.json "target/kibana-coverage/jest/jest-merged-coverage-$(date +%s%3N).json" \ No newline at end of file +mv target/kibana-coverage/jest-combined/coverage-final.json "target/kibana-coverage/jest/jest-merged-coverage-$(date +%s%3N).json" diff --git a/.buildkite/scripts/steps/test/jest.sh b/.buildkite/scripts/steps/test/jest.sh index d2d1ed10043d6..cbf8bce703cc6 100755 --- a/.buildkite/scripts/steps/test/jest.sh +++ b/.buildkite/scripts/steps/test/jest.sh @@ -10,4 +10,4 @@ is_test_execution_step echo '--- Jest' checks-reporter-with-killswitch "Jest Unit Tests $((BUILDKITE_PARALLEL_JOB+1))" \ - .buildkite/scripts/steps/test/jest_parallel.sh + .buildkite/scripts/steps/test/jest_parallel.sh jest.config.js diff --git a/.buildkite/scripts/steps/test/jest_parallel.sh b/.buildkite/scripts/steps/test/jest_parallel.sh index bc6184c74eb4a..948a441185fca 100755 --- a/.buildkite/scripts/steps/test/jest_parallel.sh +++ b/.buildkite/scripts/steps/test/jest_parallel.sh @@ -25,6 +25,6 @@ while read -r config; do ((i=i+1)) # uses heredoc to avoid the while loop being in a sub-shell thus unable to overwrite exitCode -done <<< "$(find src x-pack packages -name ${1:-jest.config.js} -not -path "*/__fixtures__/*" | sort)" +done <<< "$(find src x-pack packages -name "$1" -not -path "*/__fixtures__/*" | sort)" exit $exitCode diff --git a/jest.config.integration.js b/jest.config.integration.js deleted file mode 100644 index a2ac498986c08..0000000000000 --- a/jest.config.integration.js +++ /dev/null @@ -1,27 +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. - */ - -const Fs = require('fs'); -const Path = require('path'); - -module.exports = { - preset: '@kbn/test/jest_integration', - rootDir: '.', - roots: [ - '/src', - '/packages', - ...Fs.readdirSync(Path.resolve(__dirname, 'x-pack')).flatMap((name) => { - // create roots for all x-pack/* dirs except for test - if (name !== 'test' && Fs.statSync(Path.resolve(__dirname, 'x-pack', name)).isDirectory()) { - return [`/x-pack/${name}`]; - } - - return []; - }), - ], -}; diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index ae07034c10781..0000000000000 --- a/jest.config.js +++ /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 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: '.', - projects: [ - '/packages/*/jest.config.js', - '/src/*/jest.config.js', - '/src/plugins/*/jest.config.js', - '/src/plugins/chart_expressions/*/jest.config.js', - '/src/plugins/vis_types/*/jest.config.js', - '/test/*/jest.config.js', - '/x-pack/plugins/*/jest.config.js', - '/x-pack/plugins/security_solution/*/jest.config.js', - '/x-pack/plugins/security_solution/public/*/jest.config.js', - '/x-pack/plugins/security_solution/server/*/jest.config.js', - ], -}; diff --git a/packages/kbn-test/src/jest/run_check_jest_configs_cli.ts b/packages/kbn-test/src/jest/run_check_jest_configs_cli.ts index 6f7836e98d346..9522d9dafd6fd 100644 --- a/packages/kbn-test/src/jest/run_check_jest_configs_cli.ts +++ b/packages/kbn-test/src/jest/run_check_jest_configs_cli.ts @@ -10,7 +10,7 @@ import { writeFileSync } from 'fs'; import path from 'path'; import Mustache from 'mustache'; -import { run } from '@kbn/dev-utils'; +import { run, createFailError } from '@kbn/dev-utils'; import { REPO_ROOT } from '@kbn/utils'; import { JestConfigs, CONFIG_NAMES } from './configs'; @@ -72,7 +72,7 @@ export async function runCheckJestConfigsCli() { log.info('created %s', file); }); } else { - log.info( + throw createFailError( `Run 'node scripts/check_jest_configs --fix' to create the missing config files` ); } diff --git a/scripts/jest_integration.js b/scripts/jest_integration.js index 7332f368b31e2..9b4157eddaaf8 100755 --- a/scripts/jest_integration.js +++ b/scripts/jest_integration.js @@ -9,4 +9,4 @@ require('../src/setup_node_env/ensure_node_preserve_symlinks'); process.argv.push('--runInBand'); -require('@kbn/test').runJest('jest.config.integration.js'); +require('@kbn/test').runJest('jest.integration.config.js'); From a6b5c86159aac42acd2e78221c8a095d37916216 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 16 Feb 2022 00:17:34 -0500 Subject: [PATCH 38/39] skip failing test suite (#125455) --- x-pack/test/functional/apps/ml/model_management/model_list.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/ml/model_management/model_list.ts b/x-pack/test/functional/apps/ml/model_management/model_list.ts index 08fb3b7124aec..811ae280e0780 100644 --- a/x-pack/test/functional/apps/ml/model_management/model_list.ts +++ b/x-pack/test/functional/apps/ml/model_management/model_list.ts @@ -10,7 +10,8 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); - describe('trained models', function () { + // Failing: See https://github.com/elastic/kibana/issues/125455 + describe.skip('trained models', function () { before(async () => { await ml.trainedModels.createTestTrainedModels('classification', 15, true); await ml.trainedModels.createTestTrainedModels('regression', 15); From cf25ac3d1f8edff8f20003add707bfdc85d89fd4 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Wed, 16 Feb 2022 10:22:27 +0300 Subject: [PATCH 39/39] [MetricVis] Add possibility to configure label font and position in metric unified renderer (#125251) * Add `labelFont` and `LabelPosition` args in metric expression * Fix CI * Fixed remarks * Some fixes Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../expression_metric/common/constants.ts | 5 ++ .../metric_vis_function.test.ts.snap | 8 ++++ .../metric_vis_function.test.ts | 3 ++ .../metric_vis_function.ts | 48 ++++++++++++++++++- .../common/types/expression_functions.ts | 4 +- .../common/types/expression_renderers.ts | 8 +++- .../__stories__/metric_renderer.stories.tsx | 42 +++++++++++++++- .../metric_component.test.tsx.snap | 36 ++++++++++++-- .../public/components/metric.scss | 2 + .../components/metric_component.test.tsx | 3 ++ .../public/components/metric_component.tsx | 2 +- .../public/components/metric_value.test.tsx | 38 ++++++++++++--- .../public/components/metric_value.tsx | 17 +++++-- .../snapshots/baseline/combined_test3.json | 2 +- .../snapshots/baseline/final_output_test.json | 2 +- .../snapshots/baseline/metric_all_data.json | 2 +- .../snapshots/baseline/metric_empty_data.json | 2 +- .../baseline/metric_multi_metric_data.json | 2 +- .../baseline/metric_percentage_mode.json | 2 +- .../baseline/metric_single_metric_data.json | 2 +- .../snapshots/baseline/partial_test_2.json | 2 +- .../snapshots/baseline/step_output_test3.json | 2 +- 22 files changed, 206 insertions(+), 28 deletions(-) diff --git a/src/plugins/chart_expressions/expression_metric/common/constants.ts b/src/plugins/chart_expressions/expression_metric/common/constants.ts index b39902f61ac45..03e8852f8155a 100644 --- a/src/plugins/chart_expressions/expression_metric/common/constants.ts +++ b/src/plugins/chart_expressions/expression_metric/common/constants.ts @@ -7,3 +7,8 @@ */ export const EXPRESSION_METRIC_NAME = 'metricVis'; + +export const LabelPosition = { + BOTTOM: 'bottom', + TOP: 'top', +} as const; diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/__snapshots__/metric_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_metric/common/expression_functions/__snapshots__/metric_vis_function.test.ts.snap index 51a4f92dc2876..d242dfb4cf42a 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/__snapshots__/metric_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/__snapshots__/metric_vis_function.test.ts.snap @@ -42,7 +42,15 @@ Object { "metric": Object { "autoScale": undefined, "labels": Object { + "position": "bottom", "show": true, + "style": Object { + "css": "", + "spec": Object { + "fontSize": "24px", + }, + "type": "style", + }, }, "metricColorMode": "None", "palette": Object { diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts index faf2f93e4d188..30b6954b7c88c 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts @@ -10,6 +10,7 @@ import { metricVisFunction } from './metric_vis_function'; import type { MetricArguments } from '../../common'; import { functionWrapper } from '../../../../expressions/common/expression_functions/specs/tests/utils'; import { Datatable } from '../../../../expressions/common/expression_types/specs'; +import { LabelPosition } from '../constants'; describe('interpreter/functions#metric', () => { const fn = functionWrapper(metricVisFunction()); @@ -35,6 +36,8 @@ describe('interpreter/functions#metric', () => { }, showLabels: true, font: { spec: { fontSize: '60px' }, type: 'style', css: '' }, + labelFont: { spec: { fontSize: '24px' }, type: 'style', css: '' }, + labelPosition: LabelPosition.BOTTOM, metric: [ { type: 'vis_dimension', diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts index 26d8964c37ec2..5909ed60da141 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts @@ -12,7 +12,31 @@ import { visType } from '../types'; import { prepareLogTable, Dimension } from '../../../../visualizations/common/prepare_log_table'; import { ColorMode } from '../../../../charts/common'; import { MetricVisExpressionFunctionDefinition } from '../types'; -import { EXPRESSION_METRIC_NAME } from '../constants'; +import { EXPRESSION_METRIC_NAME, LabelPosition } from '../constants'; + +const validateOptions = ( + value: string, + availableOptions: Record, + getErrorMessage: () => string +) => { + if (!Object.values(availableOptions).includes(value)) { + throw new Error(getErrorMessage()); + } +}; + +const errors = { + invalidColorModeError: () => + i18n.translate('expressionMetricVis.function.errors.invalidColorModeError', { + defaultMessage: 'Invalid color mode is specified. Supported color modes: {colorModes}', + values: { colorModes: Object.values(ColorMode).join(', ') }, + }), + invalidLabelPositionError: () => + i18n.translate('expressionMetricVis.function.errors.invalidLabelPositionError', { + defaultMessage: + 'Invalid label position is specified. Supported label positions: {labelPosition}', + values: { labelPosition: Object.values(LabelPosition).join(', ') }, + }), +}; export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ name: EXPRESSION_METRIC_NAME, @@ -57,6 +81,21 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ }), default: `{font size=60 align="center"}`, }, + labelFont: { + types: ['style'], + help: i18n.translate('expressionMetricVis.function.labelFont.help', { + defaultMessage: 'Label font settings.', + }), + default: `{font size=24 align="center"}`, + }, + labelPosition: { + types: ['string'], + options: [LabelPosition.BOTTOM, LabelPosition.TOP], + help: i18n.translate('expressionMetricVis.function.labelPosition.help', { + defaultMessage: 'Label position', + }), + default: LabelPosition.BOTTOM, + }, metric: { types: ['vis_dimension'], help: i18n.translate('expressionMetricVis.function.metric.help', { @@ -84,6 +123,9 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ throw new Error('Palette must be provided when using percentageMode'); } + validateOptions(args.colorMode, ColorMode, errors.invalidColorModeError); + validateOptions(args.labelPosition, LabelPosition, errors.invalidLabelPositionError); + if (handlers?.inspectorAdapters?.tables) { const argsTable: Dimension[] = [ [ @@ -118,6 +160,10 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ metricColorMode: args.colorMode, labels: { show: args.showLabels, + position: args.labelPosition, + style: { + ...args.labelFont, + }, }, style: { bgColor: args.colorMode === ColorMode.Background, diff --git a/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts index b4b981340e66a..acce56ef2878d 100644 --- a/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts @@ -14,7 +14,7 @@ import { } from '../../../../expressions'; import { ExpressionValueVisDimension } from '../../../../visualizations/common'; import { ColorMode, CustomPaletteState, PaletteOutput } from '../../../../charts/common'; -import { VisParams, visType } from './expression_renderers'; +import { VisParams, visType, LabelPositionType } from './expression_renderers'; import { EXPRESSION_METRIC_NAME } from '../constants'; export interface MetricArguments { @@ -23,6 +23,8 @@ export interface MetricArguments { showLabels: boolean; palette?: PaletteOutput; font: Style; + labelFont: Style; + labelPosition: LabelPositionType; metric: ExpressionValueVisDimension[]; bucket?: ExpressionValueVisDimension; autoScale?: boolean; diff --git a/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts index 37c79b6700767..3804d015d5803 100644 --- a/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { $Values } from '@kbn/utility-types'; import { ExpressionValueVisDimension } from '../../../../visualizations/common'; import { ColorMode, @@ -14,6 +15,7 @@ import { Style as ChartStyle, } from '../../../../charts/common'; import { Style } from '../../../../expressions/common'; +import { LabelPosition } from '../constants'; export const visType = 'metric'; @@ -22,13 +24,17 @@ export interface DimensionsVisParam { bucket?: ExpressionValueVisDimension; } +export type LabelPositionType = $Values; + export type MetricStyle = Style & Pick; + +export type LabelsConfig = Labels & { style: Style; position: LabelPositionType }; export interface MetricVisParam { percentageMode: boolean; percentageFormatPattern?: string; metricColorMode: ColorMode; palette?: CustomPaletteState; - labels: Labels; + labels: LabelsConfig; style: MetricStyle; autoScale?: boolean; } diff --git a/src/plugins/chart_expressions/expression_metric/public/__stories__/metric_renderer.stories.tsx b/src/plugins/chart_expressions/expression_metric/public/__stories__/metric_renderer.stories.tsx index 18f97f23538c2..2dd8f4d19cfaa 100644 --- a/src/plugins/chart_expressions/expression_metric/public/__stories__/metric_renderer.stories.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/__stories__/metric_renderer.stories.tsx @@ -15,6 +15,7 @@ import { Render } from '../../../../presentation_util/public/__stories__'; import { ColorMode, CustomPaletteState } from '../../../../charts/common'; import { getMetricVisRenderer } from '../expression_renderers'; import { MetricStyle, MetricVisRenderConfig, visType } from '../../common/types'; +import { LabelPosition } from '../../common/constants'; const palette: CustomPaletteState = { colors: ['rgb(219 231 38)', 'rgb(112 38 231)', 'rgb(38 124 231)'], @@ -57,7 +58,11 @@ const config: MetricVisRenderConfig = { visConfig: { metric: { metricColorMode: ColorMode.None, - labels: { show: true }, + labels: { + show: true, + style: { spec: {}, type: 'style', css: '' }, + position: LabelPosition.BOTTOM, + }, percentageMode: false, style, }, @@ -132,7 +137,14 @@ storiesOf('renderers/visMetric', module) ...config, visConfig: { ...config.visConfig, - metric: { ...config.visConfig.metric, labels: { show: false } }, + metric: { + ...config.visConfig.metric, + labels: { + show: false, + style: { spec: {}, type: 'style', css: '' }, + position: LabelPosition.BOTTOM, + }, + }, }, }} {...containerSize} @@ -160,6 +172,32 @@ storiesOf('renderers/visMetric', module) /> ); }) + .add('With label position is top and custom font for label', () => { + return ( + + ); + }) .add('With color ranges, background color mode', () => { return ( , ({ getFormatService: () => { @@ -54,6 +55,8 @@ describe('MetricVisComponent', function () { }, labels: { show: true, + style: { spec: {}, type: 'style', css: '' }, + position: LabelPosition.BOTTOM, }, }, dimensions: { diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx index ed9c2667dbddd..2bde9c84db4df 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx @@ -123,7 +123,7 @@ class MetricVisComponent extends Component { onFilter={ this.props.visParams.dimensions.bucket ? () => this.filterBucket(index) : undefined } - showLabel={this.props.visParams.metric.labels.show} + labelConfig={this.props.visParams.metric.labels} /> ); }; diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx index 9a9e0eef5df97..872dea382e1c0 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx @@ -10,7 +10,8 @@ import React from 'react'; import { shallow } from 'enzyme'; import { MetricVisValue } from './metric_value'; -import { MetricOptions, MetricStyle } from '../../common/types'; +import { MetricOptions, MetricStyle, VisParams } from '../../common/types'; +import { LabelPosition } from '../../common/constants'; const baseMetric: MetricOptions = { label: 'Foo', value: 'foo', lightText: false }; const font: MetricStyle = { @@ -24,30 +25,50 @@ const font: MetricStyle = { /* stylelint-enable */ }; +const labelConfig: VisParams['metric']['labels'] = { + show: true, + position: LabelPosition.BOTTOM, + style: { spec: {}, type: 'style', css: '' }, +}; + describe('MetricVisValue', () => { it('should be wrapped in button if having a click listener', () => { const component = shallow( - {}} /> + {}} + labelConfig={labelConfig} + /> ); expect(component.find('button').exists()).toBe(true); }); it('should not be wrapped in button without having a click listener', () => { - const component = shallow(); + const component = shallow( + + ); expect(component.find('button').exists()).toBe(false); }); it('should add -isfilterable class if onFilter is provided', () => { const onFilter = jest.fn(); const component = shallow( - + ); component.simulate('click'); expect(component.find('.mtrVis__container-isfilterable')).toHaveLength(1); }); it('should not add -isfilterable class if onFilter is not provided', () => { - const component = shallow(); + const component = shallow( + + ); component.simulate('click'); expect(component.find('.mtrVis__container-isfilterable')).toHaveLength(0); }); @@ -55,7 +76,12 @@ describe('MetricVisValue', () => { it('should call onFilter callback if provided', () => { const onFilter = jest.fn(); const component = shallow( - + ); component.simulate('click'); expect(onFilter).toHaveBeenCalled(); diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx index 54662ee647b6a..7ca16dcaf862d 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx @@ -8,16 +8,16 @@ import React, { CSSProperties } from 'react'; import classNames from 'classnames'; -import type { MetricOptions, MetricStyle } from '../../common/types'; +import type { MetricOptions, MetricStyle, MetricVisParam } from '../../common/types'; interface MetricVisValueProps { metric: MetricOptions; onFilter?: () => void; - showLabel?: boolean; style: MetricStyle; + labelConfig: MetricVisParam['labels']; } -export const MetricVisValue = ({ style, metric, onFilter, showLabel }: MetricVisValueProps) => { +export const MetricVisValue = ({ style, metric, onFilter, labelConfig }: MetricVisValueProps) => { const containerClassName = classNames('mtrVis__container', { // eslint-disable-next-line @typescript-eslint/naming-convention 'mtrVis__container--light': metric.lightText, @@ -43,7 +43,16 @@ export const MetricVisValue = ({ style, metric, onFilter, showLabel }: MetricVis */ dangerouslySetInnerHTML={{ __html: metric.value }} // eslint-disable-line react/no-danger /> - {showLabel &&
{metric.label}
} + {labelConfig.show && ( +
+ {metric.label} +
+ )} ); diff --git a/test/interpreter_functional/snapshots/baseline/combined_test3.json b/test/interpreter_functional/snapshots/baseline/combined_test3.json index 160c990ff6dd3..b4ecd60f8ac59 100644 --- a/test/interpreter_functional/snapshots/baseline/combined_test3.json +++ b/test/interpreter_functional/snapshots/baseline/combined_test3.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"show":true},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/final_output_test.json b/test/interpreter_functional/snapshots/baseline/final_output_test.json index 160c990ff6dd3..b4ecd60f8ac59 100644 --- a/test/interpreter_functional/snapshots/baseline/final_output_test.json +++ b/test/interpreter_functional/snapshots/baseline/final_output_test.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"show":true},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_all_data.json b/test/interpreter_functional/snapshots/baseline/metric_all_data.json index 30bbae45b95d1..ec04c29dfdcd7 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_all_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_all_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"string","params":{}},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"show":true},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"string","params":{}},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_empty_data.json b/test/interpreter_functional/snapshots/baseline/metric_empty_data.json index ca2ee347582fb..a866540900e95 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_empty_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_empty_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"show":true},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json b/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json index 65a241e76ecda..dbb63cd00e070 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"show":true},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json b/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json index 0d767e8fdddec..b43986250d4c7 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json +++ b/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"show":true},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json b/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json index 626ab36bacc96..d65dd9dc4a775 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"show":true},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/partial_test_2.json b/test/interpreter_functional/snapshots/baseline/partial_test_2.json index 160c990ff6dd3..b4ecd60f8ac59 100644 --- a/test/interpreter_functional/snapshots/baseline/partial_test_2.json +++ b/test/interpreter_functional/snapshots/baseline/partial_test_2.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"show":true},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/step_output_test3.json b/test/interpreter_functional/snapshots/baseline/step_output_test3.json index 160c990ff6dd3..b4ecd60f8ac59 100644 --- a/test/interpreter_functional/snapshots/baseline/step_output_test3.json +++ b/test/interpreter_functional/snapshots/baseline/step_output_test3.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"show":true},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file