diff --git a/.buildkite/pipelines/hourly.yml b/.buildkite/pipelines/hourly.yml index a236f9c37b313..1335866675564 100644 --- a/.buildkite/pipelines/hourly.yml +++ b/.buildkite/pipelines/hourly.yml @@ -19,24 +19,28 @@ steps: label: 'Default CI Group' parallelism: 27 agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 250 key: default-cigroup retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: CI_GROUP=Docker .buildkite/scripts/steps/functional/xpack_cigroup.sh label: 'Docker CI Group' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 key: default-cigroup-docker retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 @@ -44,78 +48,92 @@ steps: label: 'OSS CI Group' parallelism: 11 agents: - queue: ci-group-4d + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 key: oss-cigroup retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/oss_accessibility.sh label: 'OSS Accessibility Tests' agents: - queue: ci-group-4d + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/xpack_accessibility.sh label: 'Default Accessibility Tests' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/oss_firefox.sh label: 'OSS Firefox Tests' agents: - queue: ci-group-4d + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/xpack_firefox.sh label: 'Default Firefox Tests' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/oss_misc.sh label: 'OSS Misc Functional Tests' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/xpack_saved_object_field_metrics.sh label: 'Saved Object Field Metrics' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 @@ -123,31 +141,47 @@ steps: label: 'Jest Tests' parallelism: 8 agents: - queue: n2-4 + queue: n2-4-spot timeout_in_minutes: 90 key: jest + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/test/jest_integration.sh label: 'Jest Integration Tests' parallelism: 3 agents: - queue: n2-4 + queue: n2-4-spot timeout_in_minutes: 120 key: jest-integration + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/test/api_integration.sh label: 'API Integration Tests' agents: - queue: n2-2 + queue: n2-2-spot timeout_in_minutes: 120 key: api-integration + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/lint.sh label: 'Linting' agents: - queue: n2-2 + queue: n2-2-spot key: linting timeout_in_minutes: 90 + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/lint_with_types.sh label: 'Linting (with types)' @@ -166,9 +200,13 @@ steps: - command: .buildkite/scripts/steps/storybooks/build_and_upload.sh label: 'Build Storybooks' agents: - queue: c2-4 + queue: n2-4-spot key: storybooks timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 - wait: ~ continue_on_failure: true diff --git a/.i18nrc.json b/.i18nrc.json index 402932902f249..71b68d2c51d85 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -31,6 +31,7 @@ "expressions": "src/plugins/expressions", "expressionShape": "src/plugins/expression_shape", "expressionTagcloud": "src/plugins/chart_expressions/expression_tagcloud", + "eventAnnotation": "src/plugins/event_annotation", "fieldFormats": "src/plugins/field_formats", "flot": "packages/kbn-flot-charts/lib", "home": "src/plugins/home", @@ -50,7 +51,10 @@ "kibana-react": "src/plugins/kibana_react", "kibanaOverview": "src/plugins/kibana_overview", "lists": "packages/kbn-securitysolution-list-utils/src", - "management": ["src/legacy/core_plugins/management", "src/plugins/management"], + "management": [ + "src/legacy/core_plugins/management", + "src/plugins/management" + ], "monaco": "packages/kbn-monaco/src", "navigation": "src/plugins/navigation", "newsfeed": "src/plugins/newsfeed", @@ -62,8 +66,13 @@ "sharedUX": "src/plugins/shared_ux", "sharedUXComponents": "packages/kbn-shared-ux-components/src", "statusPage": "src/legacy/core_plugins/status_page", - "telemetry": ["src/plugins/telemetry", "src/plugins/telemetry_management_section"], - "timelion": ["src/plugins/vis_types/timelion"], + "telemetry": [ + "src/plugins/telemetry", + "src/plugins/telemetry_management_section" + ], + "timelion": [ + "src/plugins/vis_types/timelion" + ], "uiActions": "src/plugins/ui_actions", "uiActionsExamples": "examples/ui_action_examples", "usageCollection": "src/plugins/usage_collection", @@ -83,4 +92,4 @@ "visualizations": "src/plugins/visualizations" }, "translations": [] -} +} \ No newline at end of file diff --git a/dev_docs/contributing/standards.mdx b/dev_docs/contributing/standards.mdx index d2f31f3a4faa2..cef9199aee924 100644 --- a/dev_docs/contributing/standards.mdx +++ b/dev_docs/contributing/standards.mdx @@ -69,7 +69,7 @@ Every team should be collecting telemetry metrics on it’s public API usage. Th ### APM -Kibana server and client are instrumented with APM node and APM RUM clients respectively, tracking serveral types of transactions by default, such as `page-load`, `request`, etc. +Kibana server and client are instrumented with APM node and APM RUM clients respectively, tracking several types of transactions by default, such as `page-load`, `request`, etc. You may introduce custom transactions. Please refer to the [APM documentation](https://www.elastic.co/guide/en/apm/get-started/current/index.html) and follow these guidelines when doing so: - Use dashed syntax for transaction types and names: `my-transaction-type` and `my-transaction-name` diff --git a/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx b/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx index 195e5c1f6f211..417d6e4983d4f 100644 --- a/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx +++ b/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx @@ -153,7 +153,7 @@ plugins to customize the Kibana experience. Examples of extension points are: - core.overlays.showModal - embeddables.registerEmbeddableFactory - uiActions.registerAction -- core.saedObjects.registerType +- core.savedObjects.registerType ## Follow up material diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index bf81ab1e0bec4..aefaf4eab40fa 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -94,6 +94,10 @@ This API doesn't support angular, for registering angular dev tools, bootstrap a |This plugin contains reusable code in the form of self-contained modules (or libraries). Each of these modules exports a set of functionality relevant to the domain of the module. +|{kib-repo}blob/{branch}/src/plugins/event_annotation/README.md[eventAnnotation] +|The Event Annotation service contains expressions for event annotations + + |{kib-repo}blob/{branch}/src/plugins/expression_error/README.md[expressionError] |Expression Error plugin adds an error renderer to the expression plugin. The renderer will display the error image. diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 2b36e1fb66185..23487f1ff3d88 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -282,7 +282,7 @@ on the {kib} index at startup. {kib} users still need to authenticate with {es}, which is proxied through the {kib} server. |[[elasticsearch-service-account-token]] `elasticsearch.serviceAccountToken:` - | beta[]. If your {es} is protected with basic authentication, this token provides the credentials + | If your {es} is protected with basic authentication, this token provides the credentials that the {kib} server uses to perform maintenance on the {kib} index at startup. This setting is an alternative to `elasticsearch.username` and `elasticsearch.password`. diff --git a/examples/search_examples/public/application.tsx b/examples/search_examples/public/application.tsx index c77c3c24be147..9bd5bb0f3f8a2 100644 --- a/examples/search_examples/public/application.tsx +++ b/examples/search_examples/public/application.tsx @@ -16,12 +16,17 @@ import { SearchExamplePage, ExampleLink } from './common/example_page'; import { SearchExamplesApp } from './search/app'; import { SearchSessionsExampleApp } from './search_sessions/app'; import { RedirectAppLinks } from '../../../src/plugins/kibana_react/public'; +import { SqlSearchExampleApp } from './sql_search/app'; const LINKS: ExampleLink[] = [ { path: '/search', title: 'Search', }, + { + path: '/sql-search', + title: 'SQL Search', + }, { path: '/search-sessions', title: 'Search Sessions', @@ -51,12 +56,16 @@ export const renderApp = ( /> + + + + diff --git a/examples/search_examples/public/sql_search/app.tsx b/examples/search_examples/public/sql_search/app.tsx new file mode 100644 index 0000000000000..acb640c4d82db --- /dev/null +++ b/examples/search_examples/public/sql_search/app.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; + +import { + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPanel, + EuiSuperUpdateButton, + EuiText, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; + +import { CoreStart } from '../../../../src/core/public'; + +import { + DataPublicPluginStart, + IKibanaSearchResponse, + isCompleteResponse, + isErrorResponse, +} from '../../../../src/plugins/data/public'; +import { + SQL_SEARCH_STRATEGY, + SqlSearchStrategyRequest, + SqlSearchStrategyResponse, +} from '../../../../src/plugins/data/common'; + +interface SearchExamplesAppDeps { + notifications: CoreStart['notifications']; + data: DataPublicPluginStart; +} + +export const SqlSearchExampleApp = ({ notifications, data }: SearchExamplesAppDeps) => { + const [sqlQuery, setSqlQuery] = useState(''); + const [request, setRequest] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [rawResponse, setRawResponse] = useState>({}); + + function setResponse(response: IKibanaSearchResponse) { + setRawResponse(response.rawResponse); + } + + const doSearch = async () => { + const req: SqlSearchStrategyRequest = { + params: { + query: sqlQuery, + }, + }; + + // Submit the search request using the `data.search` service. + setRequest(req.params!); + setIsLoading(true); + + data.search + .search(req, { + strategy: SQL_SEARCH_STRATEGY, + }) + .subscribe({ + next: (res) => { + if (isCompleteResponse(res)) { + setIsLoading(false); + setResponse(res); + } else if (isErrorResponse(res)) { + setIsLoading(false); + setResponse(res); + notifications.toasts.addDanger('An error has occurred'); + } + }, + error: (e) => { + setIsLoading(false); + data.search.showError(e); + }, + }); + }; + + return ( + + + +

SQL search example

+
+
+ + + + + + setSqlQuery(e.target.value)} + fullWidth + data-test-subj="sqlQueryInput" + /> + + + + + + + + + + + +

Request

+
+ + {JSON.stringify(request, null, 2)} + +
+
+ + + +

Response

+
+ + {JSON.stringify(rawResponse, null, 2)} + +
+
+
+
+
+
+ ); +}; diff --git a/package.json b/package.json index 24367fa77216e..af0168e125544 100644 --- a/package.json +++ b/package.json @@ -241,7 +241,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", - "elastic-apm-node": "^3.30.0", + "elastic-apm-node": "^3.31.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", "expiry-js": "0.1.7", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 396ffd4599284..526c1ff5dad82 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -124,3 +124,4 @@ pageLoadAssetSize: sessionView: 77750 cloudSecurityPosture: 19109 visTypeGauge: 24113 + eventAnnotation: 19334 diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts index bf3d066d59f25..a5eb4f976debd 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts @@ -41,9 +41,7 @@ export interface UseExceptionListsProps { namespaceTypes: NamespaceType[]; notifications: NotificationsStart; initialPagination?: Pagination; - showTrustedApps: boolean; - showEventFilters: boolean; - showHostIsolationExceptions: boolean; + hideLists?: readonly string[]; } export interface UseExceptionListProps { diff --git a/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts b/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts index 55c1d4dfaa853..c73405f1950b8 100644 --- a/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts +++ b/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts @@ -39,9 +39,7 @@ const DEFAULT_PAGINATION = { * @param filterOptions filter by certain fields * @param namespaceTypes spaces to be searched * @param notifications kibana service for displaying toasters - * @param showTrustedApps boolean - include/exclude trusted app lists - * @param showEventFilters boolean - include/exclude event filters lists - * @param showHostIsolationExceptions boolean - include/exclude host isolation exceptions lists + * @param hideLists a list of listIds we don't want to query * @param initialPagination * */ @@ -52,9 +50,7 @@ export const useExceptionLists = ({ filterOptions = {}, namespaceTypes, notifications, - showTrustedApps = false, - showEventFilters = false, - showHostIsolationExceptions = false, + hideLists = [], }: UseExceptionListsProps): ReturnExceptionLists => { const [exceptionLists, setExceptionLists] = useState([]); const [pagination, setPagination] = useState(initialPagination); @@ -67,11 +63,9 @@ export const useExceptionLists = ({ getFilters({ filters: filterOptions, namespaceTypes, - showTrustedApps, - showEventFilters, - showHostIsolationExceptions, + hideLists, }), - [namespaceTypes, filterOptions, showTrustedApps, showEventFilters, showHostIsolationExceptions] + [namespaceTypes, filterOptions, hideLists] ); const fetchData = useCallback(async (): Promise => { diff --git a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts deleted file mode 100644 index 934a9cbff56a6..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { getEventFiltersFilter } from '.'; - -describe('getEventFiltersFilter', () => { - test('it returns filter to search for "exception-list" namespace trusted apps', () => { - const filter = getEventFiltersFilter(true, ['exception-list']); - - expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_event_filters*)'); - }); - - test('it returns filter to search for "exception-list" and "agnostic" namespace trusted apps', () => { - const filter = getEventFiltersFilter(true, ['exception-list', 'exception-list-agnostic']); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' - ); - }); - - test('it returns filter to exclude "exception-list" namespace trusted apps', () => { - const filter = getEventFiltersFilter(false, ['exception-list']); - - expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_event_filters*)'); - }); - - test('it returns filter to exclude "exception-list" and "agnostic" namespace trusted apps', () => { - const filter = getEventFiltersFilter(false, ['exception-list', 'exception-list-agnostic']); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' - ); - }); -}); diff --git a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts deleted file mode 100644 index 7e55073228fca..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts +++ /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. - */ - -import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants'; -import { SavedObjectType } from '../types'; - -export const getEventFiltersFilter = ( - showEventFilter: boolean, - namespaceTypes: SavedObjectType[] -): string => { - if (showEventFilter) { - const filters = namespaceTypes.map((namespace) => { - return `${namespace}.attributes.list_id: ${ENDPOINT_EVENT_FILTERS_LIST_ID}*`; - }); - return `(${filters.join(' OR ')})`; - } else { - const filters = namespaceTypes.map((namespace) => { - return `not ${namespace}.attributes.list_id: ${ENDPOINT_EVENT_FILTERS_LIST_ID}*`; - }); - return `(${filters.join(' AND ')})`; - } -}; diff --git a/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts index 6484ac002d56d..8636984135792 100644 --- a/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts +++ b/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts @@ -10,423 +10,198 @@ import { getFilters } from '.'; describe('getFilters', () => { describe('single', () => { - test('it properly formats when no filters passed "showTrustedApps", "showEventFilters", and "showHostIsolationExceptions" is false', () => { + test('it properly formats when no filters and hide lists contains few list ids', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(not exception-list.attributes.list_id: listId-1*) AND (not exception-list.attributes.list_id: listId-2*) AND (not exception-list.attributes.list_id: listId-3*)' ); }); - test('it properly formats when no filters passed "showTrustedApps", "showEventFilters", and "showHostIsolationExceptions" is true', () => { + test('it properly formats when no filters and hide lists contains one list id', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual('(not exception-list.attributes.list_id: listId-1*)'); }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['single'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showTrustedApps" is true', () => { + test('it properly formats when no filters and no hide lists', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: [], }); - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual(''); }); - - test('it if filters passed and "showTrustedApps" is true', () => { + test('it properly formats when filters passed and hide lists contains few list ids', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: listId-1*) AND (not exception-list.attributes.list_id: listId-2*) AND (not exception-list.attributes.list_id: listId-3*)' ); }); - - test('it properly formats when no filters passed and "showEventFilters" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it if filters passed and "showEventFilters" is true', () => { + test('it properly formats when filters passed and hide lists contains one list id', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: listId-1*)' ); }); - - test('it if filters passed and "showHostIsolationExceptions" is true', () => { + test('it properly formats when filters passed and no hide lists', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: [], }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample)' ); }); }); describe('agnostic', () => { - test('it properly formats when no filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { + test('it properly formats when no filters and hide lists contains few list ids', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(not exception-list-agnostic.attributes.list_id: listId-1*) AND (not exception-list-agnostic.attributes.list_id: listId-2*) AND (not exception-list-agnostic.attributes.list_id: listId-3*)' ); }); - - test('it properly formats when no filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { + test('it properly formats when no filters and hide lists contains one list id', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['agnostic'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, - }); - - expect(filter).toEqual( - '(exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['agnostic'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); - expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual('(not exception-list-agnostic.attributes.list_id: listId-1*)'); }); - - test('it properly formats when no filters passed and "showTrustedApps" is true', () => { + test('it properly formats when no filters and no hide lists', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['agnostic'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: [], }); - expect(filter).toEqual( - '(exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual(''); }); - - test('it if filters passed and "showTrustedApps" is true', () => { + test('it properly formats when filters passed and hide lists contains few list ids', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['agnostic'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showEventFilters" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: listId-1*) AND (not exception-list-agnostic.attributes.list_id: listId-2*) AND (not exception-list-agnostic.attributes.list_id: listId-3*)' ); }); - - test('it if filters passed and "showEventFilters" is true', () => { + test('it properly formats when filters passed and hide lists contains one list id', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); expect(filter).toEqual( - '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: listId-1*)' ); }); - - test('it if filters passed and "showHostIsolationExceptions" is true', () => { + test('it properly formats when filters passed and no hide lists', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: [], }); expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample)' ); }); }); describe('single, agnostic', () => { - test('it properly formats when no filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { + test('it properly formats when no filters and hide lists contains few list ids', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*) AND (not exception-list.attributes.list_id: listId-2* AND not exception-list-agnostic.attributes.list_id: listId-2*) AND (not exception-list.attributes.list_id: listId-3* AND not exception-list-agnostic.attributes.list_id: listId-3*)' ); }); - test('it properly formats when no filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { + test('it properly formats when no filters and hide lists contains one list id', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, - }); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1'], }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)' ); }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['single', 'agnostic'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showTrustedApps" is true', () => { + test('it properly formats when no filters and no hide lists', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: [], }); - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual(''); }); - - test('it properly formats when filters passed and "showTrustedApps" is true', () => { + test('it properly formats when filters passed and hide lists contains few list ids', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showEventFilters" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*) AND (not exception-list.attributes.list_id: listId-2* AND not exception-list-agnostic.attributes.list_id: listId-2*) AND (not exception-list.attributes.list_id: listId-3* AND not exception-list-agnostic.attributes.list_id: listId-3*)' ); }); - - test('it properly formats when filters passed and "showEventFilters" is true', () => { + test('it properly formats when filters passed and hide lists contains one list id', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - test('it properly formats when no filters passed and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)' ); }); - - test('it properly formats when filters passed and "showHostIsolationExceptions" is true', () => { + test('it properly formats when filters passed and no hide lists', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: [], }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample)' ); }); }); diff --git a/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts b/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts index e8e9e6a581828..214fd396d0918 100644 --- a/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts @@ -9,34 +9,23 @@ import { ExceptionListFilter, NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; import { getGeneralFilters } from '../get_general_filters'; import { getSavedObjectTypes } from '../get_saved_object_types'; -import { getTrustedAppsFilter } from '../get_trusted_apps_filter'; -import { getEventFiltersFilter } from '../get_event_filters_filter'; -import { getHostIsolationExceptionsFilter } from '../get_host_isolation_exceptions_filter'; - export interface GetFiltersParams { filters: ExceptionListFilter; namespaceTypes: NamespaceType[]; - showTrustedApps: boolean; - showEventFilters: boolean; - showHostIsolationExceptions: boolean; + hideLists: readonly string[]; } -export const getFilters = ({ - filters, - namespaceTypes, - showTrustedApps, - showEventFilters, - showHostIsolationExceptions, -}: GetFiltersParams): string => { +export const getFilters = ({ filters, namespaceTypes, hideLists }: GetFiltersParams): string => { const namespaces = getSavedObjectTypes({ namespaceType: namespaceTypes }); const generalFilters = getGeneralFilters(filters, namespaces); - const trustedAppsFilter = getTrustedAppsFilter(showTrustedApps, namespaces); - const eventFiltersFilter = getEventFiltersFilter(showEventFilters, namespaces); - const hostIsolationExceptionsFilter = getHostIsolationExceptionsFilter( - showHostIsolationExceptions, - namespaces - ); - return [generalFilters, trustedAppsFilter, eventFiltersFilter, hostIsolationExceptionsFilter] + const hideListsFilters = hideLists.map((listId) => { + const filtersByNamespace = namespaces.map((namespace) => { + return `not ${namespace}.attributes.list_id: ${listId}*`; + }); + return `(${filtersByNamespace.join(' AND ')})`; + }); + + return [generalFilters, ...hideListsFilters] .filter((filter) => filter.trim() !== '') .join(' AND '); }; diff --git a/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.test.ts deleted file mode 100644 index 30466f459cf65..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { getHostIsolationExceptionsFilter } from '.'; - -describe('getHostIsolationExceptionsFilter', () => { - test('it returns filter to search for "exception-list" namespace host isolation exceptions', () => { - const filter = getHostIsolationExceptionsFilter(true, ['exception-list']); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it returns filter to search for "exception-list" and "agnostic" namespace host isolation exceptions', () => { - const filter = getHostIsolationExceptionsFilter(true, [ - 'exception-list', - 'exception-list-agnostic', - ]); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it returns filter to exclude "exception-list" namespace host isolation exceptions', () => { - const filter = getHostIsolationExceptionsFilter(false, ['exception-list']); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it returns filter to exclude "exception-list" and "agnostic" namespace host isolation exceptions', () => { - const filter = getHostIsolationExceptionsFilter(false, [ - 'exception-list', - 'exception-list-agnostic', - ]); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); -}); diff --git a/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.ts b/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.ts deleted file mode 100644 index d61f8fe7dac19..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.ts +++ /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. - */ - -import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID } from '@kbn/securitysolution-list-constants'; -import { SavedObjectType } from '../types'; - -export const getHostIsolationExceptionsFilter = ( - showFilter: boolean, - namespaceTypes: SavedObjectType[] -): string => { - if (showFilter) { - const filters = namespaceTypes.map((namespace) => { - return `${namespace}.attributes.list_id: ${ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID}*`; - }); - return `(${filters.join(' OR ')})`; - } else { - const filters = namespaceTypes.map((namespace) => { - return `not ${namespace}.attributes.list_id: ${ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID}*`; - }); - return `(${filters.join(' AND ')})`; - } -}; diff --git a/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.test.ts deleted file mode 100644 index da178b15390e6..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { getTrustedAppsFilter } from '.'; - -describe('getTrustedAppsFilter', () => { - test('it returns filter to search for "exception-list" namespace trusted apps', () => { - const filter = getTrustedAppsFilter(true, ['exception-list']); - - expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_trusted_apps*)'); - }); - - test('it returns filter to search for "exception-list" and "agnostic" namespace trusted apps', () => { - const filter = getTrustedAppsFilter(true, ['exception-list', 'exception-list-agnostic']); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' - ); - }); - - test('it returns filter to exclude "exception-list" namespace trusted apps', () => { - const filter = getTrustedAppsFilter(false, ['exception-list']); - - expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_trusted_apps*)'); - }); - - test('it returns filter to exclude "exception-list" and "agnostic" namespace trusted apps', () => { - const filter = getTrustedAppsFilter(false, ['exception-list', 'exception-list-agnostic']); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' - ); - }); -}); diff --git a/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.ts b/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.ts deleted file mode 100644 index 9c969068d4edf..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.ts +++ /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. - */ - -import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '@kbn/securitysolution-list-constants'; -import { SavedObjectType } from '../types'; - -export const getTrustedAppsFilter = ( - showTrustedApps: boolean, - namespaceTypes: SavedObjectType[] -): string => { - if (showTrustedApps) { - const filters = namespaceTypes.map((namespace) => { - return `${namespace}.attributes.list_id: ${ENDPOINT_TRUSTED_APPS_LIST_ID}*`; - }); - return `(${filters.join(' OR ')})`; - } else { - const filters = namespaceTypes.map((namespace) => { - return `not ${namespace}.attributes.list_id: ${ENDPOINT_TRUSTED_APPS_LIST_ID}*`; - }); - return `(${filters.join(' AND ')})`; - } -}; diff --git a/packages/kbn-securitysolution-list-utils/src/index.ts b/packages/kbn-securitysolution-list-utils/src/index.ts index 9e88cac6b5d19..a9fb3d9c3dbc7 100644 --- a/packages/kbn-securitysolution-list-utils/src/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/index.ts @@ -13,7 +13,6 @@ export * from './get_general_filters'; export * from './get_ids_and_namespaces'; export * from './get_saved_object_type'; export * from './get_saved_object_types'; -export * from './get_trusted_apps_filter'; export * from './has_large_value_list'; export * from './helpers'; export * from './types'; diff --git a/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts index ee2d8764a30af..5bb84816b1602 100644 --- a/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts @@ -20,10 +20,31 @@ describe('validateFilePathInput', () => { describe('windows', () => { const os = OperatingSystem.WINDOWS; + it('does not warn on valid filenames', () => { + expect( + validateFilePathInput({ + os, + value: 'C:\\Windows\\*\\FILENAME.EXE-1231205124.gz', + }) + ).not.toBeDefined(); + expect( + validateFilePathInput({ + os, + value: "C:\\Windows\\*\\test$ as2@13---12!@#A,DS.#$^&$!#~ 'as'd.华语.txt", + }) + ).toEqual(undefined); + }); + it('warns on wildcard in file name at the end of the path', () => { expect(validateFilePathInput({ os, value: 'c:\\path*.exe' })).toEqual( FILENAME_WILDCARD_WARNING ); + expect( + validateFilePathInput({ + os, + value: 'C:\\Windows\\*\\FILENAME.EXE-*.gz', + }) + ).toEqual(FILENAME_WILDCARD_WARNING); }); it('warns on unix paths or non-windows paths', () => { @@ -34,6 +55,7 @@ describe('validateFilePathInput', () => { expect(validateFilePathInput({ os, value: 'c:\\path/opt' })).toEqual(FILEPATH_WARNING); expect(validateFilePathInput({ os, value: '1242' })).toEqual(FILEPATH_WARNING); expect(validateFilePathInput({ os, value: 'w12efdfa' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: 'c:\\folder\\' })).toEqual(FILEPATH_WARNING); }); }); describe('unix paths', () => { @@ -42,8 +64,22 @@ describe('validateFilePathInput', () => { ? OperatingSystem.MAC : OperatingSystem.LINUX; + it('does not warn on valid filenames', () => { + expect(validateFilePathInput({ os, value: '/opt/*/FILENAME.EXE-1231205124.gz' })).not.toEqual( + FILENAME_WILDCARD_WARNING + ); + expect( + validateFilePathInput({ + os, + value: "/opt/*/test$ as2@13---12!@#A,DS.#$^&$!#~ 'as'd.华语.txt", + }) + ).not.toEqual(FILENAME_WILDCARD_WARNING); + }); it('warns on wildcard in file name at the end of the path', () => { expect(validateFilePathInput({ os, value: '/opt/bin*' })).toEqual(FILENAME_WILDCARD_WARNING); + expect(validateFilePathInput({ os, value: '/opt/FILENAME.EXE-*.gz' })).toEqual( + FILENAME_WILDCARD_WARNING + ); }); it('warns on windows paths', () => { @@ -54,6 +90,7 @@ describe('validateFilePathInput', () => { expect(validateFilePathInput({ os, value: 'opt/bin\\file.exe' })).toEqual(FILEPATH_WARNING); expect(validateFilePathInput({ os, value: '1242' })).toEqual(FILEPATH_WARNING); expect(validateFilePathInput({ os, value: 'w12efdfa' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: '/folder/' })).toEqual(FILEPATH_WARNING); }); }); }); @@ -577,50 +614,82 @@ describe('Unacceptable Mac/Linux exact paths', () => { }); }); -describe('Executable filenames with wildcard PATHS', () => { +describe('hasSimpleExecutableName', () => { it('should return TRUE when MAC/LINUX wildcard paths have an executable name', () => { + const os = + parseInt((Math.random() * 2).toString(), 10) === 1 + ? OperatingSystem.MAC + : OperatingSystem.LINUX; + expect( hasSimpleExecutableName({ - os: OperatingSystem.LINUX, + os, type: 'wildcard', value: '/opt/*/app', }) ).toEqual(true); expect( hasSimpleExecutableName({ - os: OperatingSystem.MAC, + os, type: 'wildcard', value: '/op*/**/app.dmg', }) ).toEqual(true); - }); - - it('should return TRUE when WINDOWS wildcards paths have a executable name', () => { expect( hasSimpleExecutableName({ - os: OperatingSystem.WINDOWS, + os, type: 'wildcard', - value: 'c:\\**\\path.exe', + value: "/sy*/test$ as2@13---12!@#A,DS.#$^&$!#~ 'as'd.华语.txt", }) ).toEqual(true); }); it('should return FALSE when MAC/LINUX wildcard paths have a wildcard in executable name', () => { + const os = + parseInt((Math.random() * 2).toString(), 10) === 1 + ? OperatingSystem.MAC + : OperatingSystem.LINUX; + expect( hasSimpleExecutableName({ - os: OperatingSystem.LINUX, + os, type: 'wildcard', value: '/op/*/*pp', }) ).toEqual(false); expect( hasSimpleExecutableName({ - os: OperatingSystem.MAC, + os, type: 'wildcard', value: '/op*/b**/ap.m**', }) ).toEqual(false); }); + + it('should return TRUE when WINDOWS wildcards paths have a executable name', () => { + expect( + hasSimpleExecutableName({ + os: OperatingSystem.WINDOWS, + type: 'wildcard', + value: 'c:\\**\\path.exe', + }) + ).toEqual(true); + expect( + hasSimpleExecutableName({ + os: OperatingSystem.WINDOWS, + type: 'wildcard', + value: 'C:\\*\\file-name.path华语 1234.txt', + }) + ).toEqual(true); + expect( + hasSimpleExecutableName({ + os: OperatingSystem.WINDOWS, + type: 'wildcard', + value: "C:\\*\\test$ as2@13---12!@#A,DS.#$^&$!#~ 'as'd.华语.txt", + }) + ).toEqual(true); + }); + it('should return FALSE when WINDOWS wildcards paths have a wildcard in executable name', () => { expect( hasSimpleExecutableName({ diff --git a/packages/kbn-securitysolution-utils/src/path_validations/index.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.ts index 97a726703feef..665b1a0838346 100644 --- a/packages/kbn-securitysolution-utils/src/path_validations/index.ts +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.ts @@ -22,6 +22,20 @@ export const enum ConditionEntryField { SIGNER = 'process.Ext.code_signature', } +export const enum EntryFieldType { + HASH = '.hash.', + EXECUTABLE = '.executable.caseless', + PATH = '.path', + SIGNER = '.Ext.code_signature', +} + +export type TrustedAppConditionEntryField = + | 'process.hash.*' + | 'process.executable.caseless' + | 'process.Ext.code_signature'; +export type BlocklistConditionEntryField = 'file.hash.*' | 'file.path' | 'file.Ext.code_signature'; +export type AllConditionEntryFields = TrustedAppConditionEntryField | BlocklistConditionEntryField; + export const enum OperatingSystem { LINUX = 'linux', MAC = 'macos', @@ -31,20 +45,6 @@ export const enum OperatingSystem { export type EntryTypes = 'match' | 'wildcard' | 'match_any'; export type TrustedAppEntryTypes = Extract; -/* - * regex to match executable names - * starts matching from the eol of the path - * file names with a single or multiple spaces (for spaced names) - * and hyphens and combinations of these that produce complex names - * such as: - * c:\home\lib\dmp.dmp - * c:\home\lib\my-binary-app-+/ some/ x/ dmp.dmp - * /home/lib/dmp.dmp - * /home/lib/my-binary-app+-\ some\ x\ dmp.dmp - */ -export const WIN_EXEC_PATH = /(\\[-\w]+|\\[-\w]+[\.]+[\w]+)$/i; -export const UNIX_EXEC_PATH = /(\/[-\w]+|\/[-\w]+[\.]+[\w]+)$/i; - export const validateFilePathInput = ({ os, value = '', @@ -70,7 +70,7 @@ export const validateFilePathInput = ({ } if (isValidFilePath) { - if (!hasSimpleFileName) { + if (hasSimpleFileName !== undefined && !hasSimpleFileName) { return FILENAME_WILDCARD_WARNING; } } else { @@ -86,9 +86,14 @@ export const hasSimpleExecutableName = ({ os: OperatingSystem; type: EntryTypes; value: string; -}): boolean => { +}): boolean | undefined => { + const separator = os === OperatingSystem.WINDOWS ? '\\' : '/'; + const lastString = value.split(separator).pop(); + if (!lastString) { + return; + } if (type === 'wildcard') { - return os === OperatingSystem.WINDOWS ? WIN_EXEC_PATH.test(value) : UNIX_EXEC_PATH.test(value); + return (lastString.split('*').length || lastString.split('?').length) === 1; } return true; }; @@ -100,7 +105,7 @@ export const isPathValid = ({ value, }: { os: OperatingSystem; - field: ConditionEntryField | 'file.path.text'; + field: AllConditionEntryFields | 'file.path.text'; type: EntryTypes; value: string; }): boolean => { diff --git a/src/dev/build/args.test.ts b/src/dev/build/args.test.ts index c06c13230c63f..b0f39840ba440 100644 --- a/src/dev/build/args.test.ts +++ b/src/dev/build/args.test.ts @@ -36,6 +36,7 @@ it('build default and oss dist for current platform, without packages, by defaul "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": false, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -67,6 +68,7 @@ it('builds packages if --all-platforms is passed', () => { "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": true, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -98,6 +100,7 @@ it('limits packages if --rpm passed with --all-platforms', () => { "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": true, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -129,6 +132,7 @@ it('limits packages if --deb passed with --all-platforms', () => { "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": false, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -161,6 +165,7 @@ it('limits packages if --docker passed with --all-platforms', () => { "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": false, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -200,6 +205,7 @@ it('limits packages if --docker passed with --skip-docker-ubi and --all-platform "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": false, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -232,6 +238,7 @@ it('limits packages if --all-platforms passed with --skip-docker-ubuntu', () => "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": true, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, diff --git a/src/dev/build/args.ts b/src/dev/build/args.ts index 03fe49b72c954..2bad2c0721e2e 100644 --- a/src/dev/build/args.ts +++ b/src/dev/build/args.ts @@ -22,6 +22,7 @@ export function readCliArgs(argv: string[]) { 'skip-os-packages', 'rpm', 'deb', + 'docker-cross-compile', 'docker-images', 'docker-push', 'skip-docker-contexts', @@ -52,6 +53,7 @@ export function readCliArgs(argv: string[]) { rpm: null, deb: null, 'docker-images': null, + 'docker-cross-compile': false, 'docker-push': false, 'docker-tag-qualifier': null, 'version-qualifier': '', @@ -112,6 +114,7 @@ export function readCliArgs(argv: string[]) { const buildOptions: BuildOptions = { isRelease: Boolean(flags.release), versionQualifier: flags['version-qualifier'], + dockerCrossCompile: Boolean(flags['docker-cross-compile']), dockerPush: Boolean(flags['docker-push']), dockerTagQualifier: flags['docker-tag-qualifier'], initialize: !Boolean(flags['skip-initialize']), diff --git a/src/dev/build/build_distributables.ts b/src/dev/build/build_distributables.ts index 4fb849988cb60..d2b2d24667bce 100644 --- a/src/dev/build/build_distributables.ts +++ b/src/dev/build/build_distributables.ts @@ -13,6 +13,7 @@ import * as Tasks from './tasks'; export interface BuildOptions { isRelease: boolean; + dockerCrossCompile: boolean; dockerPush: boolean; dockerTagQualifier: string | null; downloadFreshNode: boolean; diff --git a/src/dev/build/cli.ts b/src/dev/build/cli.ts index ffcbb68215ab7..561e2aea5c15d 100644 --- a/src/dev/build/cli.ts +++ b/src/dev/build/cli.ts @@ -39,6 +39,7 @@ if (showHelp) { --rpm {dim Only build the rpm packages} --deb {dim Only build the deb packages} --docker-images {dim Only build the Docker images} + --docker-cross-compile {dim Produce arm64 and amd64 Docker images} --docker-contexts {dim Only build the Docker build contexts} --skip-docker-ubi {dim Don't build the docker ubi image} --skip-docker-ubuntu {dim Don't build the docker ubuntu image} diff --git a/src/dev/build/lib/build.test.ts b/src/dev/build/lib/build.test.ts index 8ea2a20d83960..3da87ff13b1ee 100644 --- a/src/dev/build/lib/build.test.ts +++ b/src/dev/build/lib/build.test.ts @@ -32,6 +32,7 @@ const config = new Config( buildSha: 'abcd1234', buildVersion: '8.0.0', }, + false, '', false, true diff --git a/src/dev/build/lib/config.test.ts b/src/dev/build/lib/config.test.ts index 3f90c8738d8e2..2195406270bdd 100644 --- a/src/dev/build/lib/config.test.ts +++ b/src/dev/build/lib/config.test.ts @@ -29,6 +29,7 @@ const setup = async ({ targetAllPlatforms = true }: { targetAllPlatforms?: boole return await Config.create({ isRelease: true, targetAllPlatforms, + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/lib/config.ts b/src/dev/build/lib/config.ts index 650af04dfd54b..2bab1d28f9ef7 100644 --- a/src/dev/build/lib/config.ts +++ b/src/dev/build/lib/config.ts @@ -17,6 +17,7 @@ interface Options { isRelease: boolean; targetAllPlatforms: boolean; versionQualifier?: string; + dockerCrossCompile: boolean; dockerTagQualifier: string | null; dockerPush: boolean; } @@ -35,6 +36,7 @@ export class Config { isRelease, targetAllPlatforms, versionQualifier, + dockerCrossCompile, dockerTagQualifier, dockerPush, }: Options) { @@ -51,6 +53,7 @@ export class Config { versionQualifier, pkg, }), + dockerCrossCompile, dockerTagQualifier, dockerPush, isRelease @@ -63,6 +66,7 @@ export class Config { private readonly nodeVersion: string, private readonly repoRoot: string, private readonly versionInfo: VersionInfo, + private readonly dockerCrossCompile: boolean, private readonly dockerTagQualifier: string | null, private readonly dockerPush: boolean, public readonly isRelease: boolean @@ -96,6 +100,13 @@ export class Config { return this.dockerPush; } + /** + * Get docker cross compile + */ + getDockerCrossCompile() { + return this.dockerCrossCompile; + } + /** * Convert an absolute path to a relative path, based from the repo */ diff --git a/src/dev/build/lib/runner.test.ts b/src/dev/build/lib/runner.test.ts index 7c49c35446833..94ff3cb338176 100644 --- a/src/dev/build/lib/runner.test.ts +++ b/src/dev/build/lib/runner.test.ts @@ -50,6 +50,7 @@ const setup = async () => { isRelease: true, targetAllPlatforms: true, versionQualifier: '-SNAPSHOT', + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/tasks/download_cloud_dependencies.ts b/src/dev/build/tasks/download_cloud_dependencies.ts index 6ecc09c21ddce..31873550f6b4a 100644 --- a/src/dev/build/tasks/download_cloud_dependencies.ts +++ b/src/dev/build/tasks/download_cloud_dependencies.ts @@ -20,18 +20,24 @@ export const DownloadCloudDependencies: Task = { const version = config.getBuildVersion(); const buildId = id.match(/[0-9]\.[0-9]\.[0-9]-[0-9a-z]{8}/); const buildIdUrl = buildId ? `${buildId[0]}/` : ''; - const architecture = process.arch === 'arm64' ? 'arm64' : 'x86_64'; - const url = `https://${subdomain}-no-kpi.elastic.co/${buildIdUrl}downloads/beats/${beat}/${beat}-${version}-linux-${architecture}.tar.gz`; - const checksum = await downloadToString({ log, url: url + '.sha512', expectStatus: 200 }); - const destination = config.resolveFromRepo('.beats', Path.basename(url)); - return downloadToDisk({ - log, - url, - destination, - shaChecksum: checksum.split(' ')[0], - shaAlgorithm: 'sha512', - maxAttempts: 3, + + const localArchitecture = [process.arch === 'arm64' ? 'arm64' : 'x86_64']; + const allArchitectures = ['arm64', 'x86_64']; + const architectures = config.getDockerCrossCompile() ? allArchitectures : localArchitecture; + const downloads = architectures.map(async (arch) => { + const url = `https://${subdomain}-no-kpi.elastic.co/${buildIdUrl}downloads/beats/${beat}/${beat}-${version}-linux-${arch}.tar.gz`; + const checksum = await downloadToString({ log, url: url + '.sha512', expectStatus: 200 }); + const destination = config.resolveFromRepo('.beats', Path.basename(url)); + return downloadToDisk({ + log, + url, + destination, + shaChecksum: checksum.split(' ')[0], + shaAlgorithm: 'sha512', + maxAttempts: 3, + }); }); + return Promise.all(downloads); }; let buildId = ''; diff --git a/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts index b1309bd05c603..c3b9cd5f8c6b1 100644 --- a/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts @@ -39,6 +39,7 @@ async function setup({ failOnUrl }: { failOnUrl?: string } = {}) { const config = await Config.create({ isRelease: true, targetAllPlatforms: true, + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts index fb0891c24f3b0..0041829984aa7 100644 --- a/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts @@ -43,6 +43,7 @@ async function setup() { const config = await Config.create({ isRelease: true, targetAllPlatforms: true, + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts index 3a71a2b06fe91..85458c29ddcff 100644 --- a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts @@ -48,6 +48,7 @@ async function setup(actualShaSums?: Record) { const config = await Config.create({ isRelease: true, targetAllPlatforms: true, + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); 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 332605e926537..3152f07628fc9 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 dockerCrossCompile = config.getDockerCrossCompile(); const publicArtifactSubdomain = config.isRelease ? 'artifacts' : 'snapshots-no-kpi'; const scope: TemplateContext = { @@ -110,7 +111,7 @@ export async function runDockerGenerator( arm64: 'aarch64', }; const buildArchitectureSupported = hostTarget[process.arch] === flags.architecture; - if (flags.architecture && !buildArchitectureSupported) { + if (flags.architecture && !buildArchitectureSupported && !dockerCrossCompile) { return; } diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts index de26705566585..a14de2a0581ff 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts @@ -23,6 +23,7 @@ function generator({ const dockerTargetName = `${imageTag}${imageFlavor}:${version}${ dockerTagQualifier ? '-' + dockerTagQualifier : '' }`; + const dockerArchitecture = architecture === 'aarch64' ? 'linux/arm64' : 'linux/amd64'; return dedent(` #!/usr/bin/env bash # @@ -59,7 +60,7 @@ function generator({ retry_docker_pull ${baseOSImage} echo "Building: kibana${imageFlavor}-docker"; \\ - docker build -t ${dockerTargetName} -f Dockerfile . || exit 1; + docker buildx build --platform ${dockerArchitecture} -t ${dockerTargetName} -f Dockerfile . || exit 1; docker save ${dockerTargetName} | gzip -c > ${dockerTargetFilename} diff --git a/src/plugins/controls/common/control_group/control_group_constants.ts b/src/plugins/controls/common/control_group/control_group_constants.ts new file mode 100644 index 0000000000000..467394614e12c --- /dev/null +++ b/src/plugins/controls/common/control_group/control_group_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 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 { ControlGroupInput } from '..'; +import { ControlStyle, ControlWidth } from '../types'; + +export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'auto'; +export const DEFAULT_CONTROL_STYLE: ControlStyle = 'oneLine'; + +export const getDefaultControlGroupInput = (): Omit => ({ + panels: {}, + defaultControlWidth: DEFAULT_CONTROL_WIDTH, + controlStyle: DEFAULT_CONTROL_STYLE, + chainingSystem: 'HIERARCHICAL', + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, + }, +}); diff --git a/src/plugins/controls/common/control_group/types.ts b/src/plugins/controls/common/control_group/types.ts index 4e1bddc08143f..988109d237cdc 100644 --- a/src/plugins/controls/common/control_group/types.ts +++ b/src/plugins/controls/common/control_group/types.ts @@ -17,11 +17,14 @@ export interface ControlPanelState i18n.translate('controls.controlGroup.manageControl.titleInputTitle', { - defaultMessage: 'Title', + defaultMessage: 'Label', }), getControlTypeTitle: () => i18n.translate('controls.controlGroup.manageControl.controlTypesTitle', { @@ -82,10 +82,6 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.management.defaultWidthTitle', { defaultMessage: 'Default size', }), - getLayoutTitle: () => - i18n.translate('controls.controlGroup.management.layoutTitle', { - defaultMessage: 'Layout', - }), getDeleteButtonTitle: () => i18n.translate('controls.controlGroup.management.delete', { defaultMessage: 'Delete control', @@ -120,18 +116,22 @@ export const ControlGroupStrings = { defaultMessage: 'Large', }), }, - controlStyle: { - getDesignSwitchLegend: () => - i18n.translate('controls.controlGroup.management.layout.designSwitchLegend', { - defaultMessage: 'Switch control designs', + labelPosition: { + getLabelPositionTitle: () => + i18n.translate('controls.controlGroup.management.labelPosition.title', { + defaultMessage: 'Label position', + }), + getLabelPositionLegend: () => + i18n.translate('controls.controlGroup.management.labelPosition.designSwitchLegend', { + defaultMessage: 'Switch label position between inline and above', }), - getSingleLineTitle: () => - i18n.translate('controls.controlGroup.management.layout.singleLine', { - defaultMessage: 'Single line', + getInlineTitle: () => + i18n.translate('controls.controlGroup.management.labelPosition.inline', { + defaultMessage: 'Inline', }), - getTwoLineTitle: () => - i18n.translate('controls.controlGroup.management.layout.twoLine', { - defaultMessage: 'Double line', + getAboveTitle: () => + i18n.translate('controls.controlGroup.management.labelPosition.above', { + defaultMessage: 'Above', }), }, deleteControls: { @@ -192,6 +192,55 @@ export const ControlGroupStrings = { defaultMessage: 'Cancel', }), }, + validateSelections: { + getValidateSelectionsTitle: () => + i18n.translate('controls.controlGroup.management.validate.title', { + defaultMessage: 'Validate user selections', + }), + getValidateSelectionsSubTitle: () => + i18n.translate('controls.controlGroup.management.validate.subtitle', { + defaultMessage: + 'Automatically ignore any control selection that would result in no data.', + }), + }, + controlChaining: { + getHierarchyTitle: () => + i18n.translate('controls.controlGroup.management.hierarchy.title', { + defaultMessage: 'Chain controls', + }), + getHierarchySubTitle: () => + i18n.translate('controls.controlGroup.management.hierarchy.subtitle', { + defaultMessage: + 'Selections in one control narrow down available options in the next. Controls are chained from left to right.', + }), + }, + querySync: { + getQuerySettingsTitle: () => + i18n.translate('controls.controlGroup.management.query.searchSettingsTitle', { + defaultMessage: 'Sync with query bar', + }), + getQuerySettingsSubtitle: () => + i18n.translate('controls.controlGroup.management.query.useAllSearchSettingsTitle', { + defaultMessage: + 'Keeps the control group in sync with the query bar by applying time range, filter pills, and queries from the query bar', + }), + getAdvancedSettingsTitle: () => + i18n.translate('controls.controlGroup.management.query.advancedSettings', { + defaultMessage: 'Advanced', + }), + getIgnoreTimerangeTitle: () => + i18n.translate('controls.controlGroup.management.query.ignoreTimerange', { + defaultMessage: 'Ignore timerange', + }), + getIgnoreQueryTitle: () => + i18n.translate('controls.controlGroup.management.query.ignoreQuery', { + defaultMessage: 'Ignore query bar', + }), + getIgnoreFilterPillsTitle: () => + i18n.translate('controls.controlGroup.management.query.ignoreFilterPills', { + defaultMessage: 'Ignore filter pills', + }), + }, }, floatingActions: { getEditButtonTitle: () => diff --git a/src/plugins/controls/public/control_group/editor/control_group_editor.tsx b/src/plugins/controls/public/control_group/editor/control_group_editor.tsx index 82eb4f4c2eb09..95e2066541b5f 100644 --- a/src/plugins/controls/public/control_group/editor/control_group_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_group_editor.tsx @@ -14,7 +14,9 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import { omit } from 'lodash'; +import fastIsEqual from 'fast-deep-equal'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiFlyoutHeader, EuiButtonGroup, @@ -28,38 +30,101 @@ import { EuiButtonEmpty, EuiSpacer, EuiCheckbox, + EuiForm, + EuiAccordion, + useGeneratedHtmlId, + EuiSwitch, + EuiText, + EuiHorizontalRule, } from '@elastic/eui'; +import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS } from './editor_constants'; import { ControlGroupStrings } from '../control_group_strings'; import { ControlStyle, ControlWidth } from '../../types'; -import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS } from './editor_constants'; +import { ParentIgnoreSettings } from '../..'; +import { ControlsPanels } from '../types'; +import { ControlGroupInput } from '..'; +import { + DEFAULT_CONTROL_WIDTH, + getDefaultControlGroupInput, +} from '../../../common/control_group/control_group_constants'; interface EditControlGroupProps { - width: ControlWidth; - controlStyle: ControlStyle; - setAllWidths: boolean; + initialInput: ControlGroupInput; controlCount: number; - updateControlStyle: (controlStyle: ControlStyle) => void; - updateWidth: (newWidth: ControlWidth) => void; - updateAllControlWidths: (newWidth: ControlWidth) => void; - onCancel: () => void; + updateInput: (input: Partial) => void; + onDeleteAll: () => void; onClose: () => void; } +type EditorControlGroupInput = ControlGroupInput & + Required>; + +const editorControlGroupInputIsEqual = (a: ControlGroupInput, b: ControlGroupInput) => + fastIsEqual(a, b); + export const ControlGroupEditor = ({ - width, - controlStyle, - setAllWidths, controlCount, - updateControlStyle, - updateWidth, - updateAllControlWidths, - onCancel, + initialInput, + updateInput, + onDeleteAll, onClose, }: EditControlGroupProps) => { - const [currentControlStyle, setCurrentControlStyle] = useState(controlStyle); - const [currentWidth, setCurrentWidth] = useState(width); - const [applyToAll, setApplyToAll] = useState(setAllWidths); + const [resetAllWidths, setResetAllWidths] = useState(false); + const advancedSettingsAccordionId = useGeneratedHtmlId({ prefix: 'advancedSettingsAccordion' }); + + const [controlGroupEditorState, setControlGroupEditorState] = useState({ + defaultControlWidth: DEFAULT_CONTROL_WIDTH, + ...getDefaultControlGroupInput(), + ...initialInput, + }); + + const updateControlGroupEditorSetting = useCallback( + (newSettings: Partial) => { + setControlGroupEditorState({ + ...controlGroupEditorState, + ...newSettings, + }); + }, + [controlGroupEditorState] + ); + + const updateIgnoreSetting = useCallback( + (newSettings: Partial) => { + setControlGroupEditorState({ + ...controlGroupEditorState, + ignoreParentSettings: { + ...(controlGroupEditorState.ignoreParentSettings ?? {}), + ...newSettings, + }, + }); + }, + [controlGroupEditorState] + ); + + const fullQuerySyncActive = useMemo( + () => + !Object.values(omit(controlGroupEditorState.ignoreParentSettings, 'ignoreValidations')).some( + Boolean + ), + [controlGroupEditorState] + ); + + const applyChangesToInput = useCallback(() => { + const inputToApply = { ...controlGroupEditorState }; + if (resetAllWidths) { + const newPanels = {} as ControlsPanels; + Object.entries(initialInput.panels).forEach( + ([id, panel]) => + (newPanels[id] = { + ...panel, + width: inputToApply.defaultControlWidth, + }) + ); + inputToApply.panels = newPanels; + } + if (!editorControlGroupInputIsEqual(inputToApply, initialInput)) updateInput(inputToApply); + }, [controlGroupEditorState, resetAllWidths, initialInput, updateInput]); return ( <> @@ -69,57 +134,183 @@ export const ControlGroupEditor = ({ - - { - setCurrentControlStyle(newControlStyle as ControlStyle); - }} - /> - - - - { - setCurrentWidth(newWidth as ControlWidth); - }} - /> - - {controlCount > 0 ? ( - <> - - { - setApplyToAll(e.target.checked); + + + { + // The UI copy calls this setting labelPosition, but to avoid an unnecessary migration it will be left as controlStyle in the state. + updateControlGroupEditorSetting({ controlStyle: newControlStyle as ControlStyle }); }} /> - - - {ControlGroupStrings.management.getDeleteAllButtonTitle()} - - - ) : null} + + + + <> + { + updateControlGroupEditorSetting({ + defaultControlWidth: newWidth as ControlWidth, + }); + }} + /> + {controlCount > 0 && ( + <> + + { + setResetAllWidths(e.target.checked); + }} + /> + + )} + + + + + + + { + const newSetting = !e.target.checked; + updateIgnoreSetting({ + ignoreFilters: newSetting, + ignoreTimerange: newSetting, + ignoreQuery: newSetting, + }); + }} + /> + + + +

{ControlGroupStrings.management.querySync.getQuerySettingsTitle()}

+
+ +

{ControlGroupStrings.management.querySync.getQuerySettingsSubtitle()}

+
+ + + + + updateIgnoreSetting({ ignoreTimerange: e.target.checked })} + /> + + + updateIgnoreSetting({ ignoreQuery: e.target.checked })} + /> + + + updateIgnoreSetting({ ignoreFilters: e.target.checked })} + /> + + +
+
+ + + + + updateIgnoreSetting({ ignoreValidations: !e.target.checked })} + /> + + + +

+ {ControlGroupStrings.management.validateSelections.getValidateSelectionsTitle()} +

+
+ +

+ {ControlGroupStrings.management.validateSelections.getValidateSelectionsSubTitle()} +

+
+
+
+ + + + + + updateControlGroupEditorSetting({ + chainingSystem: e.target.checked ? 'HIERARCHICAL' : 'NONE', + }) + } + /> + + + +

{ControlGroupStrings.management.controlChaining.getHierarchyTitle()}

+
+ +

{ControlGroupStrings.management.controlChaining.getHierarchySubTitle()}

+
+
+
+ {controlCount > 0 && ( + <> + + + + {ControlGroupStrings.management.getDeleteAllButtonTitle()} + + + )} +
@@ -141,15 +332,7 @@ export const ControlGroupEditor = ({ color="primary" data-test-subj="control-group-editor-save" onClick={() => { - if (currentControlStyle && currentControlStyle !== controlStyle) { - updateControlStyle(currentControlStyle); - } - if (currentWidth && currentWidth !== width) { - updateWidth(currentWidth); - } - if (applyToAll) { - updateAllControlWidths(currentWidth); - } + applyChangesToInput(); onClose(); }} > diff --git a/src/plugins/controls/public/control_group/editor/create_control.tsx b/src/plugins/controls/public/control_group/editor/create_control.tsx index 218024433802b..005341359a8a9 100644 --- a/src/plugins/controls/public/control_group/editor/create_control.tsx +++ b/src/plugins/controls/public/control_group/editor/create_control.tsx @@ -12,10 +12,10 @@ import React from 'react'; import { pluginServices } from '../../services'; import { ControlEditor } from './control_editor'; import { OverlayRef } from '../../../../../core/public'; -import { DEFAULT_CONTROL_WIDTH } from './editor_constants'; import { ControlGroupStrings } from '../control_group_strings'; import { ControlWidth, ControlInput, IEditableControlFactory } from '../../types'; import { toMountPoint } from '../../../../kibana_react/public'; +import { DEFAULT_CONTROL_WIDTH } from '../../../common/control_group/control_group_constants'; export type CreateControlButtonTypes = 'toolbar' | 'callout'; export interface CreateControlButtonProps { diff --git a/src/plugins/controls/public/control_group/editor/edit_control_group.tsx b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx index 5595e5be24b04..f21d5d550f1a3 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control_group.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx @@ -9,34 +9,20 @@ import React from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; -import { DEFAULT_CONTROL_WIDTH, DEFAULT_CONTROL_STYLE } from './editor_constants'; -import { ControlsPanels } from '../types'; -import { pluginServices } from '../../services'; -import { ControlStyle, ControlWidth } from '../../types'; -import { ControlGroupStrings } from '../control_group_strings'; import { toMountPoint } from '../../../../kibana_react/public'; -import { OverlayRef } from '../../../../../core/public'; +import { ControlGroupStrings } from '../control_group_strings'; import { ControlGroupEditor } from './control_group_editor'; +import { OverlayRef } from '../../../../../core/public'; +import { pluginServices } from '../../services'; +import { ControlGroupContainer } from '..'; export interface EditControlGroupButtonProps { - controlStyle: ControlStyle; - panels?: ControlsPanels; - defaultControlWidth?: ControlWidth; - setControlStyle: (setControlStyle: ControlStyle) => void; - setDefaultControlWidth: (defaultControlWidth: ControlWidth) => void; - setAllControlWidths: (defaultControlWidth: ControlWidth) => void; - removeEmbeddable?: (panelId: string) => void; + controlGroupContainer: ControlGroupContainer; closePopover: () => void; } export const EditControlGroup = ({ - panels, - defaultControlWidth, - controlStyle, - setControlStyle, - setDefaultControlWidth, - setAllControlWidths, - removeEmbeddable, + controlGroupContainer, closePopover, }: EditControlGroupButtonProps) => { const { overlays } = pluginServices.getServices(); @@ -45,15 +31,17 @@ export const EditControlGroup = ({ const editControlGroup = () => { const PresentationUtilProvider = pluginServices.getContextProvider(); - const onCancel = (ref: OverlayRef) => { - if (!removeEmbeddable || !panels) return; + const onDeleteAll = (ref: OverlayRef) => { openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), title: ControlGroupStrings.management.deleteControls.getDeleteAllTitle(), buttonColor: 'danger', }).then((confirmed) => { - if (confirmed) Object.keys(panels).forEach((panelId) => removeEmbeddable(panelId)); + if (confirmed) + Object.keys(controlGroupContainer.getInput().panels).forEach((panelId) => + controlGroupContainer.removeEmbeddable(panelId) + ); ref.close(); }); }; @@ -62,14 +50,10 @@ export const EditControlGroup = ({ toMountPoint( onCancel(flyoutInstance)} + initialInput={controlGroupContainer.getInput()} + updateInput={(changes) => controlGroupContainer.updateInput(changes)} + controlCount={Object.keys(controlGroupContainer.getInput().panels ?? {}).length} + onDeleteAll={() => onDeleteAll(flyoutInstance)} onClose={() => flyoutInstance.close()} /> diff --git a/src/plugins/controls/public/control_group/editor/editor_constants.ts b/src/plugins/controls/public/control_group/editor/editor_constants.ts index 4c3c4c1af7938..5acad90cfbf8f 100644 --- a/src/plugins/controls/public/control_group/editor/editor_constants.ts +++ b/src/plugins/controls/public/control_group/editor/editor_constants.ts @@ -6,12 +6,8 @@ * Side Public License, v 1. */ -import { ControlStyle, ControlWidth } from '../../types'; import { ControlGroupStrings } from '../control_group_strings'; -export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'auto'; -export const DEFAULT_CONTROL_STYLE: ControlStyle = 'oneLine'; - export const CONTROL_WIDTH_OPTIONS = [ { id: `auto`, @@ -39,11 +35,11 @@ export const CONTROL_LAYOUT_OPTIONS = [ { id: `oneLine`, 'data-test-subj': 'control-editor-layout-oneLine', - label: ControlGroupStrings.management.controlStyle.getSingleLineTitle(), + label: ControlGroupStrings.management.labelPosition.getInlineTitle(), }, { id: `twoLine`, 'data-test-subj': 'control-editor-layout-twoLine', - label: ControlGroupStrings.management.controlStyle.getTwoLineTitle(), + label: ControlGroupStrings.management.labelPosition.getAboveTitle(), }, ]; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts b/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts new file mode 100644 index 0000000000000..f0acf9ca811e8 --- /dev/null +++ b/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts @@ -0,0 +1,80 @@ +/* + * 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 { Filter } from '@kbn/es-query'; + +import { Subject } from 'rxjs'; +import { ControlEmbeddable } from '../../types'; +import { ChildEmbeddableOrderCache } from './control_group_container'; +import { EmbeddableContainerSettings, isErrorEmbeddable } from '../../../../embeddable/public'; +import { ControlGroupChainingSystem, ControlGroupInput } from '../../../common/control_group/types'; + +interface GetPrecedingFiltersProps { + id: string; + childOrder: ChildEmbeddableOrderCache; + getChild: (id: string) => ControlEmbeddable; +} + +interface OnChildChangedProps { + childOutputChangedId: string; + recalculateFilters$: Subject; + childOrder: ChildEmbeddableOrderCache; + getChild: (id: string) => ControlEmbeddable; +} + +interface ChainingSystem { + getContainerSettings: ( + initialInput: ControlGroupInput + ) => EmbeddableContainerSettings | undefined; + getPrecedingFilters: (props: GetPrecedingFiltersProps) => Filter[] | undefined; + onChildChange: (props: OnChildChangedProps) => void; +} + +export const ControlGroupChainingSystems: { + [key in ControlGroupChainingSystem]: ChainingSystem; +} = { + HIERARCHICAL: { + getContainerSettings: (initialInput) => ({ + childIdInitializeOrder: Object.values(initialInput.panels) + .sort((a, b) => (a.order > b.order ? 1 : -1)) + .map((panel) => panel.explicitInput.id), + initializeSequentially: true, + }), + getPrecedingFilters: ({ id, childOrder, getChild }) => { + let filters: Filter[] = []; + const order = childOrder.IdsToOrder?.[id]; + if (!order || order === 0) return filters; + for (let i = 0; i < order; i++) { + const embeddable = getChild(childOrder.idsInOrder[i]); + if (!embeddable || isErrorEmbeddable(embeddable)) return filters; + filters = [...filters, ...(embeddable.getOutput().filters ?? [])]; + } + return filters; + }, + onChildChange: ({ childOutputChangedId, childOrder, recalculateFilters$, getChild }) => { + if (childOutputChangedId === childOrder.lastChildId) { + // the last control's output has updated, recalculate filters + recalculateFilters$.next(); + return; + } + + // when output changes on a child which isn't the last - make the next embeddable updateInputFromParent + const nextOrder = childOrder.IdsToOrder[childOutputChangedId] + 1; + if (nextOrder >= childOrder.idsInOrder.length) return; + setTimeout( + () => getChild(childOrder.idsInOrder[nextOrder]).refreshInputFromParent(), + 1 // run on next tick + ); + }, + }, + NONE: { + getContainerSettings: () => undefined, + getPrecedingFilters: () => undefined, + onChildChange: ({ recalculateFilters$ }) => recalculateFilters$.next(), + }, +}; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index 4bae605e0ef49..e73aff832ab1e 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -40,18 +40,18 @@ import { pluginServices } from '../../services'; import { DataView } from '../../../../data_views/public'; import { ControlGroupStrings } from '../control_group_strings'; import { EditControlGroup } from '../editor/edit_control_group'; -import { DEFAULT_CONTROL_WIDTH } from '../editor/editor_constants'; import { ControlGroup } from '../component/control_group_component'; import { controlGroupReducers } from '../state/control_group_reducers'; +import { Container, EmbeddableFactory } from '../../../../embeddable/public'; import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; +import { ControlGroupChainingSystems } from './control_group_chaining_system'; import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control'; -import { Container, EmbeddableFactory, isErrorEmbeddable } from '../../../../embeddable/public'; const ControlGroupReduxWrapper = withSuspense< ReduxEmbeddableWrapperPropsWithChildren >(LazyReduxEmbeddableWrapper); -interface ChildEmbeddableOrderCache { +export interface ChildEmbeddableOrderCache { IdsToOrder: { [key: string]: number }; idsInOrder: string[]; lastChildId: string; @@ -104,22 +104,7 @@ export class ControlGroupContainer extends Container< }; private getEditControlGroupButton = (closePopover: () => void) => { - return ( - this.updateInput({ controlStyle })} - setDefaultControlWidth={(defaultControlWidth) => this.updateInput({ defaultControlWidth })} - setAllControlWidths={(defaultControlWidth) => { - Object.keys(this.getInput().panels).forEach( - (panelId) => (this.getInput().panels[panelId].width = defaultControlWidth) - ); - }} - removeEmbeddable={(id) => this.removeEmbeddable(id)} - closePopover={closePopover} - /> - ); + return ; }; /** @@ -154,12 +139,7 @@ export class ControlGroupContainer extends Container< { embeddableLoaded: {} }, pluginServices.getServices().controls.getControlFactory, parent, - { - childIdInitializeOrder: Object.values(initialInput.panels) - .sort((a, b) => (a.order > b.order ? 1 : -1)) - .map((panel) => panel.explicitInput.id), - initializeSequentially: true, - } + ControlGroupChainingSystems[initialInput.chainingSystem]?.getContainerSettings(initialInput) ); this.recalculateFilters$ = new Subject(); @@ -226,20 +206,12 @@ export class ControlGroupContainer extends Container< .pipe(anyChildChangePipe) .subscribe((childOutputChangedId) => { this.recalculateDataViews(); - if (childOutputChangedId === this.childOrderCache.lastChildId) { - // the last control's output has updated, recalculate filters - this.recalculateFilters$.next(); - return; - } - - // when output changes on a child which isn't the last - make the next embeddable updateInputFromParent - const nextOrder = this.childOrderCache.IdsToOrder[childOutputChangedId] + 1; - if (nextOrder >= Object.keys(this.children).length) return; - setTimeout( - () => - this.getChild(this.childOrderCache.idsInOrder[nextOrder]).refreshInputFromParent(), - 1 // run on next tick - ); + ControlGroupChainingSystems[this.getInput().chainingSystem].onChildChange({ + childOutputChangedId, + childOrder: this.childOrderCache, + getChild: (id) => this.getChild(id), + recalculateFilters$: this.recalculateFilters$, + }); }) ); @@ -251,18 +223,6 @@ export class ControlGroupContainer extends Container< ); }; - private getPrecedingFilters = (id: string) => { - let filters: Filter[] = []; - const order = this.childOrderCache.IdsToOrder?.[id]; - if (!order || order === 0) return filters; - for (let i = 0; i < order; i++) { - const embeddable = this.getChild(this.childOrderCache.idsInOrder[i]); - if (!embeddable || isErrorEmbeddable(embeddable)) return filters; - filters = [...filters, ...(embeddable.getOutput().filters ?? [])]; - } - return filters; - }; - private getEmbeddableOrderCache = (): ChildEmbeddableOrderCache => { const panels = this.getInput().panels; const IdsToOrder: { [key: string]: number } = {}; @@ -314,20 +274,25 @@ export class ControlGroupContainer extends Container< } return { order: nextOrder, - width: this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH, + width: this.getInput().defaultControlWidth, ...panelState, } as ControlPanelState; } protected getInheritedInput(id: string): ControlInput { - const { filters, query, ignoreParentSettings, timeRange } = this.getInput(); + const { filters, query, ignoreParentSettings, timeRange, chainingSystem } = this.getInput(); - const precedingFilters = this.getPrecedingFilters(id); + const precedingFilters = ControlGroupChainingSystems[chainingSystem].getPrecedingFilters({ + id, + childOrder: this.childOrderCache, + getChild: (getChildId: string) => this.getChild(getChildId), + }); const allFilters = [ ...(ignoreParentSettings?.ignoreFilters ? [] : filters ?? []), - ...precedingFilters, + ...(precedingFilters ?? []), ]; return { + ignoreParentSettings, filters: allFilters, query: ignoreParentSettings?.ignoreQuery ? undefined : query, timeRange: ignoreParentSettings?.ignoreTimerange ? undefined : timeRange, diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts index d2e057a613070..11bf0bbc4aa7f 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts @@ -23,6 +23,7 @@ import { createControlGroupExtract, createControlGroupInject, } from '../../../common/control_group/control_group_persistable_state'; +import { getDefaultControlGroupInput } from '../../../common/control_group/control_group_constants'; export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition { public readonly isContainerType = true; @@ -42,14 +43,7 @@ export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition }; public getDefaultInput(): Partial { - return { - panels: {}, - ignoreParentSettings: { - ignoreFilters: false, - ignoreQuery: false, - ignoreTimerange: false, - }, - }; + return getDefaultControlGroupInput(); } public create = async (initialInput: ControlGroupInput, parent?: Container) => { diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index 2575d5724535f..0f5a7524db02b 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -56,6 +56,7 @@ interface OptionsListDataFetchProps { search?: string; fieldName: string; dataViewId: string; + validate?: boolean; query?: ControlInput['query']; filters?: ControlInput['filters']; } @@ -115,6 +116,7 @@ export class OptionsListEmbeddable extends Embeddable { const dataFetchPipe = this.getInput$().pipe( map((newInput) => ({ + validate: !Boolean(newInput.ignoreParentSettings?.ignoreValidations), lastReloadRequestTime: newInput.lastReloadRequestTime, dataViewId: newInput.dataViewId, fieldName: newInput.fieldName, @@ -218,12 +220,12 @@ export class OptionsListEmbeddable extends Embeddable `${state.explicitInput.id}:`; const controlGroupReferencePrefix = 'controlGroup_'; +const controlGroupId = 'dashboard_control_group'; export const createInject = ( persistableStateService: EmbeddablePersistableStateService @@ -89,11 +90,12 @@ export const createInject = ( { ...workingState.controlGroupInput, type: CONTROL_GROUP_TYPE, + id: controlGroupId, }, controlGroupReferences ); workingState.controlGroupInput = - injectedControlGroupState as DashboardContainerControlGroupInput; + injectedControlGroupState as unknown as DashboardContainerControlGroupInput; } return workingState as EmbeddableStateWithType; @@ -155,9 +157,10 @@ export const createExtract = ( persistableStateService.extract({ ...workingState.controlGroupInput, type: CONTROL_GROUP_TYPE, + id: controlGroupId, }); workingState.controlGroupInput = - extractedControlGroupState as DashboardContainerControlGroupInput; + extractedControlGroupState as unknown as DashboardContainerControlGroupInput; const prefixedControlGroupReferences = controlGroupReferences.map((reference) => ({ ...reference, name: `${controlGroupReferencePrefix}${reference.name}`, diff --git a/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts b/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts index 95cb6c38ee9d7..ce6a1f358661e 100644 --- a/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts +++ b/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts @@ -7,57 +7,68 @@ */ import { SerializableRecord } from '@kbn/utility-types'; -import { ControlGroupInput } from '../../../controls/common'; -import { ControlStyle } from '../../../controls/common/types'; +import { ControlGroupInput, getDefaultControlGroupInput } from '../../../controls/common'; import { RawControlGroupAttributes } from '../types'; +export const getDefaultDashboardControlGroupInput = getDefaultControlGroupInput; + export const controlGroupInputToRawAttributes = ( controlGroupInput: Omit -): Omit => { +): RawControlGroupAttributes => { return { controlStyle: controlGroupInput.controlStyle, + chainingSystem: controlGroupInput.chainingSystem, panelsJSON: JSON.stringify(controlGroupInput.panels), + ignoreParentSettingsJSON: JSON.stringify(controlGroupInput.ignoreParentSettings), }; }; -export const getDefaultDashboardControlGroupInput = () => ({ - controlStyle: 'oneLine' as ControlGroupInput['controlStyle'], - panels: {}, -}); +const safeJSONParse = (jsonString?: string): OutType | undefined => { + if (!jsonString && typeof jsonString !== 'string') return; + try { + return JSON.parse(jsonString) as OutType; + } catch { + return; + } +}; export const rawAttributesToControlGroupInput = ( - rawControlGroupAttributes: Omit + rawControlGroupAttributes: RawControlGroupAttributes ): Omit | undefined => { - const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); + const defaultControlGroupInput = getDefaultControlGroupInput(); + const { chainingSystem, controlStyle, ignoreParentSettingsJSON, panelsJSON } = + rawControlGroupAttributes; + const panels = safeJSONParse(panelsJSON); + const ignoreParentSettings = + safeJSONParse(ignoreParentSettingsJSON); return { - controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle, - panels: - rawControlGroupAttributes?.panelsJSON && - typeof rawControlGroupAttributes?.panelsJSON === 'string' - ? JSON.parse(rawControlGroupAttributes?.panelsJSON) - : defaultControlGroupInput.panels, + ...defaultControlGroupInput, + ...(chainingSystem ? { chainingSystem } : {}), + ...(controlStyle ? { controlStyle } : {}), + ...(ignoreParentSettings ? { ignoreParentSettings } : {}), + ...(panels ? { panels } : {}), }; }; export const rawAttributesToSerializable = ( rawControlGroupAttributes: Omit ): SerializableRecord => { - const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); + const defaultControlGroupInput = getDefaultControlGroupInput(); return { + chainingSystem: rawControlGroupAttributes?.chainingSystem, controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle, - panels: - rawControlGroupAttributes?.panelsJSON && - typeof rawControlGroupAttributes?.panelsJSON === 'string' - ? (JSON.parse(rawControlGroupAttributes?.panelsJSON) as SerializableRecord) - : defaultControlGroupInput.panels, + ignoreParentSettings: safeJSONParse(rawControlGroupAttributes?.ignoreParentSettingsJSON) ?? {}, + panels: safeJSONParse(rawControlGroupAttributes?.panelsJSON) ?? {}, }; }; export const serializableToRawAttributes = ( - controlGroupInput: SerializableRecord -): Omit => { + serializable: SerializableRecord +): Omit => { return { - controlStyle: controlGroupInput.controlStyle as ControlStyle, - panelsJSON: JSON.stringify(controlGroupInput.panels), + controlStyle: serializable.controlStyle as RawControlGroupAttributes['controlStyle'], + chainingSystem: serializable.chainingSystem as RawControlGroupAttributes['chainingSystem'], + ignoreParentSettingsJSON: JSON.stringify(serializable.ignoreParentSettings), + panelsJSON: JSON.stringify(serializable.panels), }; }; diff --git a/src/plugins/dashboard/common/saved_dashboard_references.ts b/src/plugins/dashboard/common/saved_dashboard_references.ts index 346190e4fef91..fe549a4c13a1e 100644 --- a/src/plugins/dashboard/common/saved_dashboard_references.ts +++ b/src/plugins/dashboard/common/saved_dashboard_references.ts @@ -19,7 +19,6 @@ import { convertSavedDashboardPanelToPanelState, } from './embeddable/embeddable_saved_object_converters'; import { SavedDashboardPanel } from './types'; -import { CONTROL_GROUP_TYPE } from '../../controls/common'; export interface ExtractDeps { embeddablePersistableStateService: EmbeddablePersistableStateService; @@ -51,7 +50,6 @@ function dashboardAttributesToState(attributes: SavedObjectAttributes): { if (controlGroupPanels && typeof controlGroupPanels === 'object') { controlGroupInput = { ...rawControlGroupInput, - type: CONTROL_GROUP_TYPE, panels: controlGroupPanels, }; } diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index 29e3d48d7f0d5..49caa41251211 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -98,17 +98,19 @@ export type SavedDashboardPanel730ToLatest = Pick< // Making this interface because so much of the Container type from embeddable is tied up in public // Once that is all available from common, we should be able to move the dashboard_container type to our common as well -export interface DashboardContainerControlGroupInput extends EmbeddableStateWithType { - panels: ControlGroupInput['panels']; - controlStyle: ControlGroupInput['controlStyle']; - id: string; -} +// dashboard only persists part of the Control Group Input +export type DashboardContainerControlGroupInput = Pick< + ControlGroupInput, + 'panels' | 'chainingSystem' | 'controlStyle' | 'ignoreParentSettings' +>; -export interface RawControlGroupAttributes { - controlStyle: ControlGroupInput['controlStyle']; +export type RawControlGroupAttributes = Omit< + DashboardContainerControlGroupInput, + 'panels' | 'ignoreParentSettings' +> & { + ignoreParentSettingsJSON: string; panelsJSON: string; - id: string; -} +}; export interface DashboardContainerStateWithType extends EmbeddableStateWithType { panels: { diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index 2595824e8b02e..564080831607c 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { EmbeddablePersistableStateService } from 'src/plugins/embeddable/common'; +import { identity, pickBy } from 'lodash'; import { DashboardContainerInput } from '../..'; import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants'; import type { DashboardContainer, DashboardContainerServices } from './dashboard_container'; @@ -90,7 +91,7 @@ export class DashboardContainerFactoryDefinition const controlGroup = await controlsGroupFactory?.create({ id: `control_group_${id ?? 'new_dashboard'}`, ...getDefaultDashboardControlGroupInput(), - ...(controlGroupInput ?? {}), + ...pickBy(controlGroupInput, identity), // undefined keys in initialInput should not overwrite defaults timeRange, viewMode, filters, diff --git a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts index e421ec3477354..ba60af8d02aea 100644 --- a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts +++ b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts @@ -11,7 +11,8 @@ import deepEqual from 'fast-deep-equal'; import { compareFilters, COMPARE_ALL_OPTIONS, type Filter } from '@kbn/es-query'; import { distinctUntilChanged, distinctUntilKeyChanged } from 'rxjs/operators'; -import { DashboardContainer } from '..'; +import { pick } from 'lodash'; +import { DashboardContainer, DashboardContainerControlGroupInput } from '..'; import { DashboardState } from '../../types'; import { DashboardContainerInput, DashboardSavedObject } from '../..'; import { ControlGroupContainer, ControlGroupInput } from '../../../../controls/public'; @@ -20,13 +21,6 @@ import { getDefaultDashboardControlGroupInput, rawAttributesToControlGroupInput, } from '../../../common'; - -// only part of the control group input should be stored in dashboard state. The rest is passed down from the dashboard. -export interface DashboardControlGroupInput { - panels: ControlGroupInput['panels']; - controlStyle: ControlGroupInput['controlStyle']; -} - interface DiffChecks { [key: string]: (a?: unknown, b?: unknown) => boolean; } @@ -60,6 +54,8 @@ export const syncDashboardControlGroup = async ({ const controlGroupDiff: DiffChecks = { panels: deepEqual, controlStyle: deepEqual, + chainingSystem: deepEqual, + ignoreParentSettings: deepEqual, }; subscriptions.add( @@ -71,9 +67,12 @@ export const syncDashboardControlGroup = async ({ ) ) .subscribe(() => { - const { panels, controlStyle } = controlGroup.getInput(); + const { panels, controlStyle, chainingSystem, ignoreParentSettings } = + controlGroup.getInput(); if (!isControlGroupInputEqual()) { - dashboardContainer.updateInput({ controlGroupInput: { panels, controlStyle } }); + dashboardContainer.updateInput({ + controlGroupInput: { panels, controlStyle, chainingSystem, ignoreParentSettings }, + }); } }) ); @@ -154,17 +153,17 @@ export const syncDashboardControlGroup = async ({ }; export const controlGroupInputIsEqual = ( - a: DashboardControlGroupInput | undefined, - b: DashboardControlGroupInput | undefined + a: DashboardContainerControlGroupInput | undefined, + b: DashboardContainerControlGroupInput | undefined ) => { const defaultInput = getDefaultDashboardControlGroupInput(); const inputA = { - panels: a?.panels ?? defaultInput.panels, - controlStyle: a?.controlStyle ?? defaultInput.controlStyle, + ...defaultInput, + ...pick(a, ['panels', 'chainingSystem', 'controlStyle', 'ignoreParentSettings']), }; const inputB = { - panels: b?.panels ?? defaultInput.panels, - controlStyle: b?.controlStyle ?? defaultInput.controlStyle, + ...defaultInput, + ...pick(b, ['panels', 'chainingSystem', 'controlStyle', 'ignoreParentSettings']), }; if (deepEqual(inputA, inputB)) return true; return false; @@ -175,7 +174,12 @@ export const serializeControlGroupToDashboardSavedObject = ( dashboardState: DashboardState ) => { // only save to saved object if control group is not default - if (controlGroupInputIsEqual(dashboardState.controlGroupInput, {} as ControlGroupInput)) { + if ( + controlGroupInputIsEqual( + dashboardState.controlGroupInput, + getDefaultDashboardControlGroupInput() + ) + ) { dashboardSavedObject.controlGroupInput = undefined; return; } diff --git a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts index eea9edd13507f..ee403939a9e8c 100644 --- a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts +++ b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts @@ -10,8 +10,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { Filter, Query, TimeRange } from '../../services/data'; import { ViewMode } from '../../services/embeddable'; -import type { DashboardControlGroupInput } from '../lib/dashboard_control_group'; import { DashboardOptions, DashboardPanelMap, DashboardState } from '../../types'; +import { DashboardContainerControlGroupInput } from '../embeddable'; export const dashboardStateSlice = createSlice({ name: 'dashboardState', @@ -44,7 +44,7 @@ export const dashboardStateSlice = createSlice({ }, setControlGroupState: ( state, - action: PayloadAction + action: PayloadAction ) => { state.controlGroupInput = action.payload; }, diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index c1023f8e900bd..575124671cf2b 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -29,7 +29,11 @@ import { UrlForwardingStart } from '../../url_forwarding/public'; import { UsageCollectionSetup } from './services/usage_collection'; import { NavigationPublicPluginStart } from './services/navigation'; import { Query, RefreshInterval, TimeRange } from './services/data'; -import { DashboardPanelState, SavedDashboardPanel } from '../common/types'; +import { + DashboardContainerControlGroupInput, + DashboardPanelState, + SavedDashboardPanel, +} from '../common/types'; import { SavedObjectsTaggingApi } from './services/saved_objects_tagging_oss'; import { DataPublicPluginStart, DataViewsContract } from './services/data'; import { ContainerInput, EmbeddableInput, ViewMode } from './services/embeddable'; @@ -40,7 +44,6 @@ import type { DashboardContainer, DashboardSavedObject } from '.'; import { VisualizationsStart } from '../../visualizations/public'; import { DashboardAppLocatorParams } from './locator'; import { SpacesPluginStart } from './services/spaces'; -import type { DashboardControlGroupInput } from './application/lib/dashboard_control_group'; export type { SavedDashboardPanel }; @@ -71,7 +74,7 @@ export interface DashboardState { panels: DashboardPanelMap; timeRange?: TimeRange; - controlGroupInput?: DashboardControlGroupInput; + controlGroupInput?: DashboardContainerControlGroupInput; } /** @@ -81,7 +84,7 @@ export type RawDashboardState = Omit & { panels: Saved export interface DashboardContainerInput extends ContainerInput { dashboardCapabilities?: DashboardAppCapabilities; - controlGroupInput?: DashboardControlGroupInput; + controlGroupInput?: DashboardContainerControlGroupInput; refreshConfig?: RefreshInterval; isEmbeddedExternally?: boolean; isFullScreenMode: boolean; diff --git a/src/plugins/dashboard/server/saved_objects/dashboard.ts b/src/plugins/dashboard/server/saved_objects/dashboard.ts index 2ddbcfd9fdb74..69d0feffde27b 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard.ts @@ -55,7 +55,9 @@ export const createDashboardSavedObjectType = ({ controlGroupInput: { properties: { controlStyle: { type: 'keyword', index: false, doc_values: false }, + chainingSystem: { type: 'keyword', index: false, doc_values: false }, panelsJSON: { type: 'text', index: false }, + ignoreParentSettingsJSON: { type: 'text', index: false }, }, }, timeFrom: { type: 'keyword', index: false, doc_values: false }, diff --git a/src/plugins/data/common/search/index.ts b/src/plugins/data/common/search/index.ts index badbb94e9752f..d0d103abe1ea2 100644 --- a/src/plugins/data/common/search/index.ts +++ b/src/plugins/data/common/search/index.ts @@ -17,3 +17,4 @@ export * from './poll_search'; export * from './strategies/es_search'; export * from './strategies/eql_search'; export * from './strategies/ese_search'; +export * from './strategies/sql_search'; diff --git a/src/plugins/data/common/search/strategies/sql_search/index.ts b/src/plugins/data/common/search/strategies/sql_search/index.ts new file mode 100644 index 0000000000000..12594660136d8 --- /dev/null +++ b/src/plugins/data/common/search/strategies/sql_search/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './types'; diff --git a/src/plugins/data/common/search/strategies/sql_search/types.ts b/src/plugins/data/common/search/strategies/sql_search/types.ts new file mode 100644 index 0000000000000..e51d0bf4a6b6c --- /dev/null +++ b/src/plugins/data/common/search/strategies/sql_search/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + SqlGetAsyncRequest, + SqlQueryRequest, + SqlQueryResponse, +} from '@elastic/elasticsearch/lib/api/types'; +import { IKibanaSearchRequest, IKibanaSearchResponse } from '../../types'; + +export const SQL_SEARCH_STRATEGY = 'sql'; + +export type SqlRequestParams = + | Omit + | Omit; +export type SqlSearchStrategyRequest = IKibanaSearchRequest; + +export type SqlSearchStrategyResponse = IKibanaSearchResponse; diff --git a/src/plugins/data/server/search/README.md b/src/plugins/data/server/search/README.md index b564c34a7f8b3..d663cdc38da1b 100644 --- a/src/plugins/data/server/search/README.md +++ b/src/plugins/data/server/search/README.md @@ -10,3 +10,4 @@ The `search` plugin includes: - ES_SEARCH_STRATEGY - hitting regular es `_search` endpoint using query DSL - (default) ESE_SEARCH_STRATEGY (Enhanced ES) - hitting `_async_search` endpoint and works with search sessions - EQL_SEARCH_STRATEGY +- SQL_SEARCH_STRATEGY diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 8fb92136bc259..7c01fefc92d65 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -77,6 +77,7 @@ import { eqlRawResponse, ENHANCED_ES_SEARCH_STRATEGY, EQL_SEARCH_STRATEGY, + SQL_SEARCH_STRATEGY, } from '../../common/search'; import { getEsaggs, getEsdsl, getEql } from './expressions'; import { @@ -93,6 +94,7 @@ import { enhancedEsSearchStrategyProvider } from './strategies/ese_search'; import { eqlSearchStrategyProvider } from './strategies/eql_search'; import { NoSearchIdInSessionError } from './errors/no_search_id_in_session'; import { CachedUiSettingsClient } from './services'; +import { sqlSearchStrategyProvider } from './strategies/sql_search'; type StrategyMap = Record>; @@ -176,6 +178,7 @@ export class SearchService implements Plugin { ); this.registerSearchStrategy(EQL_SEARCH_STRATEGY, eqlSearchStrategyProvider(this.logger)); + this.registerSearchStrategy(SQL_SEARCH_STRATEGY, sqlSearchStrategyProvider(this.logger)); registerBsearchRoute( bfetch, diff --git a/src/plugins/data/server/search/strategies/sql_search/index.ts b/src/plugins/data/server/search/strategies/sql_search/index.ts new file mode 100644 index 0000000000000..9af70ddcb618d --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { sqlSearchStrategyProvider } from './sql_search_strategy'; diff --git a/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts b/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts new file mode 100644 index 0000000000000..9944de7be17be --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts @@ -0,0 +1,110 @@ +/* + * 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 { getDefaultAsyncSubmitParams, getDefaultAsyncGetParams } from './request_utils'; +import moment from 'moment'; +import { SearchSessionsConfigSchema } from '../../../../config'; + +const getMockSearchSessionsConfig = ({ + enabled = true, + defaultExpiration = moment.duration(7, 'd'), +} = {}) => + ({ + enabled, + defaultExpiration, + } as SearchSessionsConfigSchema); + +describe('request utils', () => { + describe('getDefaultAsyncSubmitParams', () => { + test('Uses `keep_alive` from default params if no `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = getDefaultAsyncSubmitParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_alive` from config if enabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '259200000ms'); + }); + + test('Uses `keepAlive` of `1m` if disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_on_completion` if enabled', async () => { + const mockConfig = getMockSearchSessionsConfig({}); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', true); + }); + + test('Does not use `keep_on_completion` if disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', false); + }); + }); + + describe('getDefaultAsyncGetParams', () => { + test('Uses `wait_for_completion_timeout`', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('wait_for_completion_timeout'); + }); + + test('Uses `keep_alive` if `sessionId` is not provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Has no `keep_alive` if `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).not.toHaveProperty('keep_alive'); + }); + + test('Uses `keep_alive` if `sessionId` is provided but sessions disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + }); +}); diff --git a/src/plugins/data/server/search/strategies/sql_search/request_utils.ts b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts new file mode 100644 index 0000000000000..d05b2710b07ea --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/request_utils.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 { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types'; +import { ISearchOptions } from '../../../../common'; +import { SearchSessionsConfigSchema } from '../../../../config'; + +/** + @internal + */ +export function getDefaultAsyncSubmitParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, + options: ISearchOptions +): Pick { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + const keepAlive = useSearchSessions + ? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms` + : '1m'; + + return { + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + // If search sessions are used, store and get an async ID even for short running requests. + keep_on_completion: useSearchSessions, + // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise. + keep_alive: keepAlive, + }; +} + +/** + @internal + */ +export function getDefaultAsyncGetParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, + options: ISearchOptions +): Pick { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + return { + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + ...(useSearchSessions + ? // Don't change the expiration of search requests that are tracked in a search session + undefined + : { + // We still need to do polling for searches not within the context of a search session or when search session disabled + keep_alive: '1m', + }), + }; +} diff --git a/src/plugins/data/server/search/strategies/sql_search/response_utils.ts b/src/plugins/data/server/search/strategies/sql_search/response_utils.ts new file mode 100644 index 0000000000000..9d6e3f4fd3ebc --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/response_utils.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 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 { SqlQueryResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { SqlSearchStrategyResponse } from '../../../../common'; + +/** + * Get the Kibana representation of an async search response + */ +export function toAsyncKibanaSearchResponse( + response: SqlQueryResponse, + warning?: string +): SqlSearchStrategyResponse { + return { + id: response.id, + rawResponse: response, + isPartial: response.is_partial, + isRunning: response.is_running, + ...(warning ? { warning } : {}), + }; +} diff --git a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts new file mode 100644 index 0000000000000..2734a512e046b --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts @@ -0,0 +1,264 @@ +/* + * 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 { KbnServerError } from '../../../../../kibana_utils/server'; +import { errors } from '@elastic/elasticsearch'; +import * as indexNotFoundException from '../../../../common/search/test_data/index_not_found_exception.json'; +import { SearchStrategyDependencies } from '../../types'; +import { sqlSearchStrategyProvider } from './sql_search_strategy'; +import { createSearchSessionsClientMock } from '../../mocks'; +import { SqlSearchStrategyRequest } from '../../../../common'; + +const mockSqlResponse = { + body: { + id: 'foo', + is_partial: false, + is_running: false, + rows: [], + }, +}; + +describe('SQL search strategy', () => { + const mockSqlGetAsync = jest.fn(); + const mockSqlQuery = jest.fn(); + const mockSqlDelete = jest.fn(); + const mockLogger: any = { + debug: () => {}, + }; + const mockDeps = { + esClient: { + asCurrentUser: { + sql: { + getAsync: mockSqlGetAsync, + query: mockSqlQuery, + deleteAsync: mockSqlDelete, + }, + }, + }, + searchSessionsClient: createSearchSessionsClientMock(), + } as unknown as SearchStrategyDependencies; + + beforeEach(() => { + mockSqlGetAsync.mockClear(); + mockSqlQuery.mockClear(); + mockSqlDelete.mockClear(); + }); + + it('returns a strategy with `search and `cancel`, `extend`', async () => { + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + expect(typeof esSearch.search).toBe('function'); + expect(typeof esSearch.cancel).toBe('function'); + expect(typeof esSearch.extend).toBe('function'); + }); + + describe('search', () => { + describe('no sessionId', () => { + it('makes a POST request with params when no ID provided', async () => { + mockSqlQuery.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ params }, {}, mockDeps).toPromise(); + + expect(mockSqlQuery).toBeCalled(); + const request = mockSqlQuery.mock.calls[0][0]; + expect(request.query).toEqual(params.query); + expect(request).toHaveProperty('format', 'json'); + expect(request).toHaveProperty('keep_alive', '1m'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + }); + + it('makes a GET request to async search with ID', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ id: 'foo', params }, {}, mockDeps).toPromise(); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request.id).toEqual('foo'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).toHaveProperty('keep_alive', '1m'); + expect(request).toHaveProperty('format', 'json'); + }); + }); + + // skip until full search session support https://github.com/elastic/kibana/issues/127880 + describe.skip('with sessionId', () => { + it('makes a POST request with params (long keepalive)', async () => { + mockSqlQuery.mockResolvedValueOnce(mockSqlResponse); + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlQuery).toBeCalled(); + const request = mockSqlQuery.mock.calls[0][0]; + expect(request.query).toEqual(params.query); + + expect(request).toHaveProperty('keep_alive', '604800000ms'); + }); + + it('makes a GET request to async search without keepalive', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request.id).toEqual('foo'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).not.toHaveProperty('keep_alive'); + }); + }); + + describe('with sessionId (until SQL ignores session Id)', () => { + it('makes a POST request with params (long keepalive)', async () => { + mockSqlQuery.mockResolvedValueOnce(mockSqlResponse); + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlQuery).toBeCalled(); + const request = mockSqlQuery.mock.calls[0][0]; + expect(request.query).toEqual(params.query); + + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).toHaveProperty('keep_alive', '1m'); + }); + + it('makes a GET request to async search with keepalive', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request.id).toEqual('foo'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).toHaveProperty('keep_alive', '1m'); + }); + }); + + it('throws normalized error if ResponseError is thrown', async () => { + const errResponse = new errors.ResponseError({ + body: indexNotFoundException, + statusCode: 404, + headers: {}, + warnings: [], + meta: {} as any, + }); + + mockSqlQuery.mockRejectedValue(errResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSqlQuery).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(404); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(indexNotFoundException); + }); + + it('throws normalized error if Error is thrown', async () => { + const errResponse = new Error('not good'); + + mockSqlQuery.mockRejectedValue(errResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSqlQuery).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(500); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(undefined); + }); + }); + + describe('cancel', () => { + it('makes a DELETE request to async search with the provided ID', async () => { + mockSqlDelete.mockResolvedValueOnce(200); + + const id = 'some_id'; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.cancel!(id, {}, mockDeps); + + expect(mockSqlDelete).toBeCalled(); + const request = mockSqlDelete.mock.calls[0][0]; + expect(request).toEqual({ id }); + }); + }); + + describe('extend', () => { + it('makes a GET request to async search with the provided ID and keepAlive', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const id = 'some_other_id'; + const keepAlive = '1d'; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + await esSearch.extend!(id, keepAlive, {}, mockDeps); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request).toEqual({ id, keep_alive: keepAlive }); + }); + }); +}); diff --git a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts new file mode 100644 index 0000000000000..51ab35af3db0f --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IScopedClusterClient, Logger } from 'kibana/server'; +import { catchError, tap } from 'rxjs/operators'; +import { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types'; +import type { ISearchStrategy, SearchStrategyDependencies } from '../../types'; +import type { + IAsyncSearchOptions, + SqlSearchStrategyRequest, + SqlSearchStrategyResponse, +} from '../../../../common'; +import { pollSearch } from '../../../../common'; +import { getDefaultAsyncGetParams, getDefaultAsyncSubmitParams } from './request_utils'; +import { toAsyncKibanaSearchResponse } from './response_utils'; +import { getKbnServerError } from '../../../../../kibana_utils/server'; + +export const sqlSearchStrategyProvider = ( + logger: Logger, + useInternalUser: boolean = false +): ISearchStrategy => { + async function cancelAsyncSearch(id: string, esClient: IScopedClusterClient) { + try { + const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser; + await client.sql.deleteAsync({ id }); + } catch (e) { + throw getKbnServerError(e); + } + } + + function asyncSearch( + { id, ...request }: SqlSearchStrategyRequest, + options: IAsyncSearchOptions, + { esClient }: SearchStrategyDependencies + ) { + const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser; + + // disable search sessions until session task manager supports SQL + // https://github.com/elastic/kibana/issues/127880 + // const sessionConfig = searchSessionsClient.getConfig(); + const sessionConfig = null; + + const search = async () => { + if (id) { + const params: SqlGetAsyncRequest = { + format: request.params?.format ?? 'json', + ...getDefaultAsyncGetParams(sessionConfig, options), + id, + }; + + const { body, headers } = await client.sql.getAsync(params, { + signal: options.abortSignal, + meta: true, + }); + + return toAsyncKibanaSearchResponse(body, headers?.warning); + } else { + const params: SqlQueryRequest = { + format: request.params?.format ?? 'json', + ...getDefaultAsyncSubmitParams(sessionConfig, options), + ...request.params, + }; + + const { headers, body } = await client.sql.query(params, { + signal: options.abortSignal, + meta: true, + }); + + return toAsyncKibanaSearchResponse(body, headers?.warning); + } + }; + + const cancel = async () => { + if (id) { + await cancelAsyncSearch(id, esClient); + } + }; + + return pollSearch(search, cancel, options).pipe( + tap((response) => (id = response.id)), + catchError((e) => { + throw getKbnServerError(e); + }) + ); + } + + return { + /** + * @param request + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Observable>` + * @throws `KbnServerError` + */ + search: (request, options: IAsyncSearchOptions, deps) => { + logger.debug(`sql search: search request=${JSON.stringify(request)}`); + + return asyncSearch(request, options, deps); + }, + /** + * @param id async search ID to cancel, as returned from _async_search API + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise` + * @throws `KbnServerError` + */ + cancel: async (id, options, { esClient }) => { + logger.debug(`sql search: cancel async_search_id=${id}`); + await cancelAsyncSearch(id, esClient); + }, + /** + * + * @param id async search ID to extend, as returned from _async_search API + * @param keepAlive + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise` + * @throws `KbnServerError` + */ + extend: async (id, keepAlive, options, { esClient }) => { + logger.debug(`sql search: extend async_search_id=${id} keep_alive=${keepAlive}`); + try { + const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser; + await client.sql.getAsync({ + id, + keep_alive: keepAlive, + }); + } catch (e) { + throw getKbnServerError(e); + } + }, + }; +}; diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index f3759ffdb39e5..3aedd2c0a3b78 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -36,6 +36,7 @@ export type { EmbeddablePackageState, EmbeddableRendererProps, EmbeddableContainerContext, + EmbeddableContainerSettings, } from './lib'; export { ACTION_ADD_PANEL, diff --git a/src/plugins/embeddable/public/lib/containers/index.ts b/src/plugins/embeddable/public/lib/containers/index.ts index 041923188e175..655fd413e3bc0 100644 --- a/src/plugins/embeddable/public/lib/containers/index.ts +++ b/src/plugins/embeddable/public/lib/containers/index.ts @@ -6,6 +6,12 @@ * Side Public License, v 1. */ -export type { IContainer, PanelState, ContainerInput, ContainerOutput } from './i_container'; +export type { + IContainer, + PanelState, + ContainerInput, + ContainerOutput, + EmbeddableContainerSettings, +} from './i_container'; export { Container } from './container'; export * from './embeddable_child_panel'; diff --git a/src/plugins/event_annotation/README.md b/src/plugins/event_annotation/README.md new file mode 100644 index 0000000000000..a7a85d3ab3641 --- /dev/null +++ b/src/plugins/event_annotation/README.md @@ -0,0 +1,3 @@ +# Event Annotation service + +The Event Annotation service contains expressions for event annotations diff --git a/src/plugins/event_annotation/common/event_annotation_group/index.ts b/src/plugins/event_annotation/common/event_annotation_group/index.ts new file mode 100644 index 0000000000000..85f1d9dff900c --- /dev/null +++ b/src/plugins/event_annotation/common/event_annotation_group/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { i18n } from '@kbn/i18n'; +import type { EventAnnotationOutput } from '../manual_event_annotation/types'; + +export interface EventAnnotationGroupOutput { + type: 'event_annotation_group'; + annotations: EventAnnotationOutput[]; +} + +export interface EventAnnotationGroupArgs { + annotations: EventAnnotationOutput[]; +} + +export function eventAnnotationGroup(): ExpressionFunctionDefinition< + 'event_annotation_group', + null, + EventAnnotationGroupArgs, + EventAnnotationGroupOutput +> { + return { + name: 'event_annotation_group', + aliases: [], + type: 'event_annotation_group', + inputTypes: ['null'], + help: i18n.translate('eventAnnotation.group.description', { + defaultMessage: 'Event annotation group', + }), + args: { + annotations: { + types: ['manual_event_annotation'], + help: i18n.translate('eventAnnotation.group.args.annotationConfigs', { + defaultMessage: 'Annotation configs', + }), + multi: true, + }, + }, + fn: (input, args) => { + return { + type: 'event_annotation_group', + annotations: args.annotations, + }; + }, + }; +} diff --git a/src/plugins/event_annotation/common/index.ts b/src/plugins/event_annotation/common/index.ts new file mode 100644 index 0000000000000..332fa19150aad --- /dev/null +++ b/src/plugins/event_annotation/common/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { EventAnnotationArgs, EventAnnotationOutput } from './manual_event_annotation/types'; +export { manualEventAnnotation } from './manual_event_annotation'; +export { eventAnnotationGroup } from './event_annotation_group'; +export type { EventAnnotationGroupArgs } from './event_annotation_group'; +export type { EventAnnotationConfig } from './types'; diff --git a/src/plugins/event_annotation/common/manual_event_annotation/index.ts b/src/plugins/event_annotation/common/manual_event_annotation/index.ts new file mode 100644 index 0000000000000..108df93b34180 --- /dev/null +++ b/src/plugins/event_annotation/common/manual_event_annotation/index.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { i18n } from '@kbn/i18n'; +import type { EventAnnotationArgs, EventAnnotationOutput } from './types'; +export const manualEventAnnotation: ExpressionFunctionDefinition< + 'manual_event_annotation', + null, + EventAnnotationArgs, + EventAnnotationOutput +> = { + name: 'manual_event_annotation', + aliases: [], + type: 'manual_event_annotation', + help: i18n.translate('eventAnnotation.manualAnnotation.description', { + defaultMessage: `Configure manual annotation`, + }), + inputTypes: ['null'], + args: { + time: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.time', { + defaultMessage: `Timestamp for annotation`, + }), + }, + label: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.label', { + defaultMessage: `The name of the annotation`, + }), + }, + color: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.color', { + defaultMessage: 'The color of the line', + }), + }, + lineStyle: { + types: ['string'], + options: ['solid', 'dotted', 'dashed'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.lineStyle', { + defaultMessage: 'The style of the annotation line', + }), + }, + lineWidth: { + types: ['number'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.lineWidth', { + defaultMessage: 'The width of the annotation line', + }), + }, + icon: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.icon', { + defaultMessage: 'An optional icon used for annotation lines', + }), + }, + textVisibility: { + types: ['boolean'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.textVisibility', { + defaultMessage: 'Visibility of the label on the annotation line', + }), + }, + isHidden: { + types: ['boolean'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.isHidden', { + defaultMessage: `Switch to hide annotation`, + }), + }, + }, + fn: function fn(input: unknown, args: EventAnnotationArgs) { + return { + type: 'manual_event_annotation', + ...args, + }; + }, +}; diff --git a/src/plugins/event_annotation/common/manual_event_annotation/types.ts b/src/plugins/event_annotation/common/manual_event_annotation/types.ts new file mode 100644 index 0000000000000..e1bed4a592d23 --- /dev/null +++ b/src/plugins/event_annotation/common/manual_event_annotation/types.ts @@ -0,0 +1,15 @@ +/* + * 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 { StyleProps } from '../types'; + +export type EventAnnotationArgs = { + time: string; +} & StyleProps; + +export type EventAnnotationOutput = EventAnnotationArgs & { type: 'manual_event_annotation' }; diff --git a/src/plugins/event_annotation/common/types.ts b/src/plugins/event_annotation/common/types.ts new file mode 100644 index 0000000000000..95275804d1d1f --- /dev/null +++ b/src/plugins/event_annotation/common/types.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type LineStyle = 'solid' | 'dashed' | 'dotted'; +export type AnnotationType = 'manual'; +export type KeyType = 'point_in_time'; + +export interface StyleProps { + label: string; + color?: string; + icon?: string; + lineWidth?: number; + lineStyle?: LineStyle; + textVisibility?: boolean; + isHidden?: boolean; +} + +export type EventAnnotationConfig = { + id: string; + key: { + type: KeyType; + timestamp: string; + }; +} & StyleProps; diff --git a/src/plugins/event_annotation/jest.config.js b/src/plugins/event_annotation/jest.config.js new file mode 100644 index 0000000000000..a6ea4a6b430df --- /dev/null +++ b/src/plugins/event_annotation/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/event_annotation'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/event_annotation', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/event_annotation/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/event_annotation/kibana.json b/src/plugins/event_annotation/kibana.json new file mode 100644 index 0000000000000..5a0c49be09ba3 --- /dev/null +++ b/src/plugins/event_annotation/kibana.json @@ -0,0 +1,17 @@ +{ + "id": "eventAnnotation", + "version": "kibana", + "server": true, + "ui": true, + "description": "The Event Annotation service contains expressions for event annotations", + "extraPublicDirs": [ + "common" + ], + "requiredPlugins": [ + "expressions" + ], + "owner": { + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" + } +} \ No newline at end of file diff --git a/src/plugins/event_annotation/public/event_annotation_service/README.md b/src/plugins/event_annotation/public/event_annotation_service/README.md new file mode 100644 index 0000000000000..a7a85d3ab3641 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/README.md @@ -0,0 +1,3 @@ +# Event Annotation service + +The Event Annotation service contains expressions for event annotations diff --git a/src/plugins/event_annotation/public/event_annotation_service/helpers.ts b/src/plugins/event_annotation/public/event_annotation_service/helpers.ts new file mode 100644 index 0000000000000..aed33da840574 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/helpers.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { euiLightVars } from '@kbn/ui-theme'; +export const defaultAnnotationColor = euiLightVars.euiColorAccent; diff --git a/src/plugins/event_annotation/public/event_annotation_service/index.tsx b/src/plugins/event_annotation/public/event_annotation_service/index.tsx new file mode 100644 index 0000000000000..e967a7cb0f0a2 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { EventAnnotationServiceType } from './types'; + +export class EventAnnotationService { + private eventAnnotationService?: EventAnnotationServiceType; + public async getService() { + if (!this.eventAnnotationService) { + const { getEventAnnotationService } = await import('./service'); + this.eventAnnotationService = getEventAnnotationService(); + } + return this.eventAnnotationService; + } +} diff --git a/src/plugins/event_annotation/public/event_annotation_service/service.tsx b/src/plugins/event_annotation/public/event_annotation_service/service.tsx new file mode 100644 index 0000000000000..3d81ea6a3e3a6 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/service.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EventAnnotationServiceType } from './types'; +import { defaultAnnotationColor } from './helpers'; + +export function hasIcon(icon: string | undefined): icon is string { + return icon != null && icon !== 'empty'; +} + +export function getEventAnnotationService(): EventAnnotationServiceType { + return { + toExpression: ({ + label, + isHidden, + color, + lineStyle, + lineWidth, + icon, + textVisibility, + time, + }) => { + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'manual_event_annotation', + arguments: { + time: [time], + label: [label], + color: [color || defaultAnnotationColor], + lineWidth: [lineWidth || 1], + lineStyle: [lineStyle || 'solid'], + icon: hasIcon(icon) ? [icon] : ['triangle'], + textVisibility: [textVisibility || false], + isHidden: [Boolean(isHidden)], + }, + }, + ], + }; + }, + }; +} diff --git a/src/plugins/event_annotation/public/event_annotation_service/types.ts b/src/plugins/event_annotation/public/event_annotation_service/types.ts new file mode 100644 index 0000000000000..bb0b6eb4cc200 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExpressionAstExpression } from '../../../expressions/common/ast'; +import { EventAnnotationArgs } from '../../common'; + +export interface EventAnnotationServiceType { + toExpression: (props: EventAnnotationArgs) => ExpressionAstExpression; +} diff --git a/src/plugins/event_annotation/public/index.ts b/src/plugins/event_annotation/public/index.ts new file mode 100644 index 0000000000000..c15429c94cbe4 --- /dev/null +++ b/src/plugins/event_annotation/public/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. + */ + +// TODO: https://github.com/elastic/kibana/issues/110891 +/* eslint-disable @kbn/eslint/no_export_all */ + +import { EventAnnotationPlugin } from './plugin'; +export const plugin = () => new EventAnnotationPlugin(); +export type { EventAnnotationPluginSetup, EventAnnotationPluginStart } from './plugin'; +export * from './event_annotation_service/types'; +export { EventAnnotationService } from './event_annotation_service'; +export { defaultAnnotationColor } from './event_annotation_service/helpers'; diff --git a/src/plugins/event_annotation/public/mocks.ts b/src/plugins/event_annotation/public/mocks.ts new file mode 100644 index 0000000000000..e78d4e8f75de7 --- /dev/null +++ b/src/plugins/event_annotation/public/mocks.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { getEventAnnotationService } from './event_annotation_service/service'; + +// not really mocking but avoiding async loading +export const eventAnnotationServiceMock = getEventAnnotationService(); diff --git a/src/plugins/event_annotation/public/plugin.ts b/src/plugins/event_annotation/public/plugin.ts new file mode 100644 index 0000000000000..83cdc0546a7f5 --- /dev/null +++ b/src/plugins/event_annotation/public/plugin.ts @@ -0,0 +1,39 @@ +/* + * 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 { Plugin, CoreSetup } from 'kibana/public'; +import { ExpressionsSetup } from '../../expressions/public'; +import { manualEventAnnotation, eventAnnotationGroup } from '../common'; +import { EventAnnotationService } from './event_annotation_service'; + +interface SetupDependencies { + expressions: ExpressionsSetup; +} + +/** @public */ +export type EventAnnotationPluginSetup = EventAnnotationService; + +/** @public */ +export type EventAnnotationPluginStart = EventAnnotationService; + +/** @public */ +export class EventAnnotationPlugin + implements Plugin +{ + private readonly eventAnnotationService = new EventAnnotationService(); + + public setup(core: CoreSetup, dependencies: SetupDependencies): EventAnnotationPluginSetup { + dependencies.expressions.registerFunction(manualEventAnnotation); + dependencies.expressions.registerFunction(eventAnnotationGroup); + return this.eventAnnotationService; + } + + public start(): EventAnnotationPluginStart { + return this.eventAnnotationService; + } +} diff --git a/src/plugins/event_annotation/server/index.ts b/src/plugins/event_annotation/server/index.ts new file mode 100644 index 0000000000000..d9d13045ed10a --- /dev/null +++ b/src/plugins/event_annotation/server/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { EventAnnotationServerPlugin } from './plugin'; +export const plugin = () => new EventAnnotationServerPlugin(); diff --git a/src/plugins/event_annotation/server/plugin.ts b/src/plugins/event_annotation/server/plugin.ts new file mode 100644 index 0000000000000..ef4e0216fb5ac --- /dev/null +++ b/src/plugins/event_annotation/server/plugin.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 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 { CoreSetup, Plugin } from 'kibana/server'; +import { manualEventAnnotation, eventAnnotationGroup } from '../common'; +import { ExpressionsServerSetup } from '../../expressions/server'; + +interface SetupDependencies { + expressions: ExpressionsServerSetup; +} + +export class EventAnnotationServerPlugin implements Plugin { + public setup(core: CoreSetup, dependencies: SetupDependencies) { + dependencies.expressions.registerFunction(manualEventAnnotation); + dependencies.expressions.registerFunction(eventAnnotationGroup); + + return {}; + } + + public start() { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/event_annotation/tsconfig.json b/src/plugins/event_annotation/tsconfig.json new file mode 100644 index 0000000000000..ca3d65a13b214 --- /dev/null +++ b/src/plugins/event_annotation/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*" + ], + "references": [ + { + "path": "../../core/tsconfig.json" + }, + { + "path": "../expressions/tsconfig.json" + } + ] +} \ No newline at end of file diff --git a/test/api_integration/apis/search/index.ts b/test/api_integration/apis/search/index.ts index d5d6e928b5483..cde0c925d91ff 100644 --- a/test/api_integration/apis/search/index.ts +++ b/test/api_integration/apis/search/index.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('search', () => { loadTestFile(require.resolve('./search')); + loadTestFile(require.resolve('./sql_search')); loadTestFile(require.resolve('./bsearch')); }); } diff --git a/test/api_integration/apis/search/sql_search.ts b/test/api_integration/apis/search/sql_search.ts new file mode 100644 index 0000000000000..c57d424e56fc7 --- /dev/null +++ b/test/api_integration/apis/search/sql_search.ts @@ -0,0 +1,90 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const sqlQuery = `SELECT index, bytes FROM "logstash-*" ORDER BY "@timestamp" DESC`; + + describe('SQL search', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + }); + + after(async () => { + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + }); + describe('post', () => { + it('should return 200 when correctly formatted searches are provided', async () => { + const resp = await supertest + .post(`/internal/search/sql`) + .send({ + params: { + query: sqlQuery, + }, + }) + .expect(200); + + expect(resp.body).to.have.property('id'); + expect(resp.body).to.have.property('isPartial'); + expect(resp.body).to.have.property('isRunning'); + expect(resp.body).to.have.property('rawResponse'); + }); + + it('should fetch search results by id', async () => { + const resp1 = await supertest + .post(`/internal/search/sql`) + .send({ + params: { + query: sqlQuery, + keep_on_completion: true, // force keeping the results even if completes early + }, + }) + .expect(200); + const id = resp1.body.id; + + const resp2 = await supertest.post(`/internal/search/sql/${id}`).send({}); + + expect(resp2.status).to.be(200); + expect(resp2.body.id).to.be(id); + expect(resp2.body).to.have.property('isPartial'); + expect(resp2.body).to.have.property('isRunning'); + expect(resp2.body).to.have.property('rawResponse'); + }); + }); + + describe('delete', () => { + it('should delete search', async () => { + const resp1 = await supertest + .post(`/internal/search/sql`) + .send({ + params: { + query: sqlQuery, + keep_on_completion: true, // force keeping the results even if completes early + }, + }) + .expect(200); + const id = resp1.body.id; + + // confirm it was saved + await supertest.post(`/internal/search/sql/${id}`).send({}).expect(200); + + // delete it + await supertest.delete(`/internal/search/sql/${id}`).send().expect(200); + + // check it was deleted + await supertest.post(`/internal/search/sql/${id}`).send({}).expect(404); + }); + }); + }); +} diff --git a/test/functional/apps/console/_autocomplete.ts b/test/functional/apps/console/_autocomplete.ts index cd17244b1f498..4b424b2a79c66 100644 --- a/test/functional/apps/console/_autocomplete.ts +++ b/test/functional/apps/console/_autocomplete.ts @@ -31,7 +31,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(PageObjects.console.isAutocompleteVisible()).to.be.eql(true); }); - describe('with a missing comma in query', () => { + // FLAKY: https://github.com/elastic/kibana/issues/126414 + describe.skip('with a missing comma in query', () => { const LINE_NUMBER = 4; beforeEach(async () => { await PageObjects.console.clearTextArea(); diff --git a/test/functional/apps/dashboard/dashboard_controls_integration.ts b/test/functional/apps/dashboard/dashboard_controls_integration.ts deleted file mode 100644 index 2ccde5251250e..0000000000000 --- a/test/functional/apps/dashboard/dashboard_controls_integration.ts +++ /dev/null @@ -1,566 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import expect from '@kbn/expect'; - -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function ({ getService, getPageObjects }: FtrProviderContext) { - const retry = getService('retry'); - const security = getService('security'); - const queryBar = getService('queryBar'); - const pieChart = getService('pieChart'); - const filterBar = getService('filterBar'); - const testSubjects = getService('testSubjects'); - const kibanaServer = getService('kibanaServer'); - const dashboardAddPanel = getService('dashboardAddPanel'); - const find = getService('find'); - const { dashboardControls, timePicker, common, dashboard, header } = getPageObjects([ - 'dashboardControls', - 'timePicker', - 'dashboard', - 'common', - 'header', - ]); - - describe('Dashboard controls integration', () => { - const clearAllControls = async () => { - const controlIds = await dashboardControls.getAllControlIds(); - for (const controlId of controlIds) { - await dashboardControls.removeExistingControl(controlId); - } - }; - - before(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - await kibanaServer.importExport.load( - 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' - ); - await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); - await kibanaServer.uiSettings.replace({ - defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', - }); - await common.navigateToApp('dashboard'); - await dashboardControls.enableControlsLab(); - await common.navigateToApp('dashboard'); - await dashboard.preserveCrossAppState(); - }); - - after(async () => { - await security.testUser.restoreDefaults(); - await kibanaServer.savedObjects.cleanStandardList(); - }); - - describe('Controls callout visibility', async () => { - before(async () => { - await dashboard.gotoDashboardLandingPage(); - await dashboard.clickNewDashboard(); - await timePicker.setDefaultDataRange(); - await dashboard.saveDashboard('Test Controls Callout'); - }); - - describe('does not show the empty control callout on an empty dashboard', async () => { - it('in view mode', async () => { - await dashboard.clickCancelOutOfEditMode(); - await testSubjects.missingOrFail('controls-empty'); - }); - - it('in edit mode', async () => { - await dashboard.switchToEditMode(); - await testSubjects.missingOrFail('controls-empty'); - }); - }); - - it('show the empty control callout on a dashboard with panels', async () => { - await dashboard.switchToEditMode(); - await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); - await testSubjects.existOrFail('controls-empty'); - }); - - it('adding control hides the empty control callout', async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - await testSubjects.missingOrFail('controls-empty'); - }); - - after(async () => { - await dashboard.clickCancelOutOfEditMode(); - await dashboard.gotoDashboardLandingPage(); - }); - }); - - describe('Control group settings', async () => { - before(async () => { - await dashboard.gotoDashboardLandingPage(); - await dashboard.clickNewDashboard(); - await dashboard.saveDashboard('Test Control Group Settings'); - }); - - it('adjust layout of controls', async () => { - await dashboard.switchToEditMode(); - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - await dashboardControls.adjustControlsLayout('twoLine'); - const controlGroupWrapper = await testSubjects.find('controls-group-wrapper'); - expect(await controlGroupWrapper.elementHasClass('controlsWrapper--twoLine')).to.be(true); - }); - - describe('apply new default size', async () => { - it('to new controls only', async () => { - await dashboardControls.updateControlsSize('medium'); - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'name.keyword', - }); - - const controlIds = await dashboardControls.getAllControlIds(); - const firstControl = await find.byXPath(`//div[@data-control-id="${controlIds[0]}"]`); - expect(await firstControl.elementHasClass('controlFrameWrapper--medium')).to.be(false); - const secondControl = await find.byXPath(`//div[@data-control-id="${controlIds[1]}"]`); - expect(await secondControl.elementHasClass('controlFrameWrapper--medium')).to.be(true); - }); - - it('to all existing controls', async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'animal.keyword', - width: 'large', - }); - - await dashboardControls.updateControlsSize('small', true); - const controlIds = await dashboardControls.getAllControlIds(); - for (const id of controlIds) { - const control = await find.byXPath(`//div[@data-control-id="${id}"]`); - expect(await control.elementHasClass('controlFrameWrapper--small')).to.be(true); - } - }); - }); - - describe('flyout only show settings that are relevant', async () => { - before(async () => { - await dashboard.switchToEditMode(); - }); - - it('when no controls', async () => { - await dashboardControls.deleteAllControls(); - await dashboardControls.openControlGroupSettingsFlyout(); - await testSubjects.missingOrFail('delete-all-controls-button'); - await testSubjects.missingOrFail('set-all-control-sizes-checkbox'); - }); - - it('when at least one control', async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - await dashboardControls.openControlGroupSettingsFlyout(); - await testSubjects.existOrFail('delete-all-controls-button'); - await testSubjects.existOrFail('set-all-control-sizes-checkbox', { allowHidden: true }); - }); - - afterEach(async () => { - await testSubjects.click('euiFlyoutCloseButton'); - }); - - after(async () => { - await dashboardControls.deleteAllControls(); - }); - }); - - after(async () => { - await dashboard.clickCancelOutOfEditMode(); - await dashboard.gotoDashboardLandingPage(); - }); - }); - - describe('Options List Control creation and editing experience', async () => { - it('can add a new options list control from a blank state', async () => { - await dashboard.clickNewDashboard(); - await timePicker.setDefaultDataRange(); - await dashboardControls.createOptionsListControl({ fieldName: 'machine.os.raw' }); - expect(await dashboardControls.getControlsCount()).to.be(1); - }); - - it('can add a second options list control with a non-default data view', async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - expect(await dashboardControls.getControlsCount()).to.be(2); - - // data views should be properly propagated from the control group to the dashboard - expect(await filterBar.getIndexPatterns()).to.be('logstash-*,animals-*'); - }); - - it('renames an existing control', async () => { - const secondId = (await dashboardControls.getAllControlIds())[1]; - - const newTitle = 'wow! Animal sounds?'; - await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlEditorSetTitle(newTitle); - await dashboardControls.controlEditorSave(); - expect(await dashboardControls.doesControlTitleExist(newTitle)).to.be(true); - }); - - it('can change the data view and field of an existing options list', async () => { - const firstId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(firstId); - - await dashboardControls.optionsListEditorSetDataView('animals-*'); - await dashboardControls.optionsListEditorSetfield('animal.keyword'); - await dashboardControls.controlEditorSave(); - - // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view - await retry.try(async () => { - await testSubjects.click('addFilter'); - const indexPatternSelectExists = await testSubjects.exists('filterIndexPatternsSelect'); - await filterBar.ensureFieldEditorModalIsClosed(); - expect(indexPatternSelectExists).to.be(false); - }); - }); - - it('deletes an existing control', async () => { - const firstId = (await dashboardControls.getAllControlIds())[0]; - - await dashboardControls.removeExistingControl(firstId); - expect(await dashboardControls.getControlsCount()).to.be(1); - }); - - after(async () => { - await clearAllControls(); - }); - }); - - describe('Interactions between options list and dashboard', async () => { - let controlId: string; - before(async () => { - await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - title: 'Animal Sounds', - }); - - controlId = (await dashboardControls.getAllControlIds())[0]; - }); - - describe('Apply dashboard query and filters to controls', async () => { - it('Applies dashboard query to options list control', async () => { - await queryBar.setQuery('isDog : true '); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(5); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'ruff', - 'bark', - 'grrr', - 'bow ow ow', - 'grr', - ]); - }); - - await queryBar.setQuery(''); - await queryBar.submitQuery(); - }); - - it('Applies dashboard filters to options list control', async () => { - await filterBar.addFilter('sound.keyword', 'is one of', ['bark', 'bow ow ow', 'ruff']); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(3); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'ruff', - 'bark', - 'bow ow ow', - ]); - }); - }); - - it('Does not apply disabled dashboard filters to options list control', async () => { - await filterBar.toggleFilterEnabled('sound.keyword'); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8); - }); - - await filterBar.toggleFilterEnabled('sound.keyword'); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - }); - - it('Negated filters apply to options control', async () => { - await filterBar.toggleFilterNegated('sound.keyword'); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(5); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'hiss', - 'grrr', - 'meow', - 'growl', - 'grr', - ]); - }); - }); - - after(async () => { - await filterBar.removeAllFilters(); - }); - }); - - describe('Selections made in control apply to dashboard', async () => { - it('Shows available options in options list', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Can search options list for available options', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSearchForOption('meo'); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'meow', - ]); - }); - await dashboardControls.optionsListPopoverClearSearch(); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Can select multiple available options', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSelectOption('hiss'); - await dashboardControls.optionsListPopoverSelectOption('grr'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Selected options appear in control', async () => { - const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); - expect(selectionString).to.be('hiss, grr'); - }); - - it('Applies options list control options to dashboard', async () => { - await retry.try(async () => { - expect(await pieChart.getPieSliceCount()).to.be(2); - }); - }); - - it('Applies options list control options to dashboard by default on open', async () => { - await dashboard.gotoDashboardLandingPage(); - await header.waitUntilLoadingHasFinished(); - await dashboard.clickUnsavedChangesContinueEditing('New Dashboard'); - await header.waitUntilLoadingHasFinished(); - expect(await pieChart.getPieSliceCount()).to.be(2); - - const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); - expect(selectionString).to.be('hiss, grr'); - }); - - after(async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverClearSelections(); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - }); - - describe('Options List dashboard validation', async () => { - before(async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSelectOption('meow'); - await dashboardControls.optionsListPopoverSelectOption('bark'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Can mark selections invalid with Query', async () => { - await queryBar.setQuery('isDog : false '); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(4); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'hiss', - 'meow', - 'growl', - 'grr', - 'Ignored selection', - 'bark', - ]); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - // only valid selections are applied as filters. - expect(await pieChart.getPieSliceCount()).to.be(1); - }); - - it('can make invalid selections valid again if the parent filter changes', async () => { - await queryBar.setQuery(''); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'hiss', - 'ruff', - 'bark', - 'grrr', - 'meow', - 'growl', - 'grr', - 'bow ow ow', - ]); - }); - - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - expect(await pieChart.getPieSliceCount()).to.be(2); - }); - - it('Can mark multiple selections invalid with Filter', async () => { - await filterBar.addFilter('sound.keyword', 'is', ['hiss']); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'hiss', - 'Ignored selections', - 'meow', - 'bark', - ]); - }); - - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - // only valid selections are applied as filters. - expect(await pieChart.getPieSliceCount()).to.be(1); - }); - }); - - after(async () => { - await filterBar.removeAllFilters(); - await clearAllControls(); - }); - }); - - describe('Control group hierarchical chaining', async () => { - let controlIds: string[]; - - const ensureAvailableOptionsEql = async (controlId: string, expectation: string[]) => { - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql( - expectation - ); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }; - - before(async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'animal.keyword', - title: 'Animal', - }); - - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'name.keyword', - title: 'Animal Name', - }); - - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - title: 'Animal Sound', - }); - - controlIds = await dashboardControls.getAllControlIds(); - }); - - it('Shows all available options in first Options List control', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[0]); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(2); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); - }); - - it('Selecting an option in the first Options List will filter the second and third controls', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[0]); - await dashboardControls.optionsListPopoverSelectOption('cat'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); - - await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester']); - await ensureAvailableOptionsEql(controlIds[2], ['hiss', 'meow', 'growl', 'grr']); - }); - - it('Selecting an option in the second Options List will filter the third control', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[1]); - await dashboardControls.optionsListPopoverSelectOption('sylvester'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[1]); - - await ensureAvailableOptionsEql(controlIds[2], ['meow', 'hiss']); - }); - - it('Can select an option in the third Options List', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[2]); - await dashboardControls.optionsListPopoverSelectOption('meow'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); - }); - - it('Selecting a conflicting option in the first control will validate the second and third controls', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[0]); - await dashboardControls.optionsListPopoverClearSelections(); - await dashboardControls.optionsListPopoverSelectOption('dog'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); - - await ensureAvailableOptionsEql(controlIds[1], [ - 'Fluffy', - 'Fee Fee', - 'Rover', - 'Ignored selection', - 'sylvester', - ]); - await ensureAvailableOptionsEql(controlIds[2], [ - 'ruff', - 'bark', - 'grrr', - 'bow ow ow', - 'grr', - 'Ignored selection', - 'meow', - ]); - }); - }); - }); -} diff --git a/test/functional/apps/dashboard/index.ts b/test/functional/apps/dashboard/index.ts index 73a8754982e4f..c9a62447f223a 100644 --- a/test/functional/apps/dashboard/index.ts +++ b/test/functional/apps/dashboard/index.ts @@ -72,7 +72,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./full_screen_mode')); loadTestFile(require.resolve('./dashboard_filter_bar')); loadTestFile(require.resolve('./dashboard_filtering')); - loadTestFile(require.resolve('./dashboard_controls_integration')); loadTestFile(require.resolve('./panel_expand_toggle')); loadTestFile(require.resolve('./dashboard_grid')); loadTestFile(require.resolve('./view_edit')); diff --git a/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts new file mode 100644 index 0000000000000..13ef3a248a583 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts @@ -0,0 +1,146 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const { dashboardControls, common, dashboard, timePicker } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'common', + ]); + + describe('Dashboard control group hierarchical chaining', () => { + let controlIds: string[]; + + const ensureAvailableOptionsEql = async (controlId: string, expectation: string[]) => { + await dashboardControls.optionsListOpenPopover(controlId); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(expectation); + }); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }; + + before(async () => { + await common.navigateToApp('dashboard'); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); + + // populate an initial set of controls and get their ids. + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'animal.keyword', + title: 'Animal', + }); + + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'name.keyword', + title: 'Animal Name', + }); + + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + title: 'Animal Sound', + }); + + controlIds = await dashboardControls.getAllControlIds(); + }); + + it('Shows all available options in first Options List control', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(2); + }); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + }); + + it('Selecting an option in the first Options List will filter the second and third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverSelectOption('cat'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester']); + await ensureAvailableOptionsEql(controlIds[2], ['hiss', 'meow', 'growl', 'grr']); + }); + + it('Selecting an option in the second Options List will filter the third control', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[1]); + await dashboardControls.optionsListPopoverSelectOption('sylvester'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[1]); + + await ensureAvailableOptionsEql(controlIds[2], ['meow', 'hiss']); + }); + + it('Can select an option in the third Options List', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[2]); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); + }); + + it('Selecting a conflicting option in the first control will validate the second and third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListPopoverSelectOption('dog'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], [ + 'Fluffy', + 'Fee Fee', + 'Rover', + 'Ignored selection', + 'sylvester', + ]); + await ensureAvailableOptionsEql(controlIds[2], [ + 'ruff', + 'bark', + 'grrr', + 'bow ow ow', + 'grr', + 'Ignored selection', + 'meow', + ]); + }); + + describe('Hierarchical chaining off', async () => { + before(async () => { + await dashboardControls.updateChainingSystem('NONE'); + }); + + it('Selecting an option in the first Options List will not filter the second or third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverSelectOption('cat'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], [ + 'Fluffy', + 'Tiger', + 'sylvester', + 'Fee Fee', + 'Rover', + ]); + await ensureAvailableOptionsEql(controlIds[2], [ + 'hiss', + 'ruff', + 'bark', + 'grrr', + 'meow', + 'growl', + 'grr', + 'bow ow ow', + ]); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts new file mode 100644 index 0000000000000..ffda165443337 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts @@ -0,0 +1,103 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const { dashboardControls, common, dashboard } = getPageObjects([ + 'dashboardControls', + 'dashboard', + 'common', + ]); + + describe('Dashboard control group settings', () => { + before(async () => { + await common.navigateToApp('dashboard'); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await dashboard.saveDashboard('Test Control Group Settings'); + }); + + it('adjust layout of controls', async () => { + await dashboard.switchToEditMode(); + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + await dashboardControls.adjustControlsLayout('twoLine'); + const controlGroupWrapper = await testSubjects.find('controls-group-wrapper'); + expect(await controlGroupWrapper.elementHasClass('controlsWrapper--twoLine')).to.be(true); + }); + + describe('apply new default size', async () => { + it('to new controls only', async () => { + await dashboardControls.updateControlsSize('medium'); + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'name.keyword', + }); + + const controlIds = await dashboardControls.getAllControlIds(); + const firstControl = await find.byXPath(`//div[@data-control-id="${controlIds[0]}"]`); + expect(await firstControl.elementHasClass('controlFrameWrapper--medium')).to.be(false); + const secondControl = await find.byXPath(`//div[@data-control-id="${controlIds[1]}"]`); + expect(await secondControl.elementHasClass('controlFrameWrapper--medium')).to.be(true); + }); + + it('to all existing controls', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'animal.keyword', + width: 'large', + }); + + await dashboardControls.updateControlsSize('small', true); + const controlIds = await dashboardControls.getAllControlIds(); + for (const id of controlIds) { + const control = await find.byXPath(`//div[@data-control-id="${id}"]`); + expect(await control.elementHasClass('controlFrameWrapper--small')).to.be(true); + } + }); + }); + + describe('flyout only show settings that are relevant', async () => { + before(async () => { + await dashboard.switchToEditMode(); + }); + + it('when no controls', async () => { + await dashboardControls.deleteAllControls(); + await dashboardControls.openControlGroupSettingsFlyout(); + await testSubjects.missingOrFail('delete-all-controls-button'); + await testSubjects.missingOrFail('set-all-control-sizes-checkbox'); + }); + + it('when at least one control', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + await dashboardControls.openControlGroupSettingsFlyout(); + await testSubjects.existOrFail('delete-all-controls-button'); + await testSubjects.existOrFail('set-all-control-sizes-checkbox', { allowHidden: true }); + }); + + afterEach(async () => { + await testSubjects.click('euiFlyoutCloseButton'); + }); + + after(async () => { + await dashboardControls.deleteAllControls(); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/controls_callout.ts b/test/functional/apps/dashboard_elements/controls/controls_callout.ts new file mode 100644 index 0000000000000..fc6316940c8a4 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/controls_callout.ts @@ -0,0 +1,63 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const { dashboardControls, timePicker, dashboard } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'common', + 'header', + ]); + + describe('Controls callout', () => { + describe('callout visibility', async () => { + before(async () => { + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); + await dashboard.saveDashboard('Test Controls Callout'); + }); + + describe('does not show the empty control callout on an empty dashboard', async () => { + it('in view mode', async () => { + await dashboard.clickCancelOutOfEditMode(); + await testSubjects.missingOrFail('controls-empty'); + }); + + it('in edit mode', async () => { + await dashboard.switchToEditMode(); + await testSubjects.missingOrFail('controls-empty'); + }); + }); + + it('show the empty control callout on a dashboard with panels', async () => { + await dashboard.switchToEditMode(); + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + await testSubjects.existOrFail('controls-empty'); + }); + + it('adding control hides the empty control callout', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + await testSubjects.missingOrFail('controls-empty'); + }); + + after(async () => { + await dashboard.clickCancelOutOfEditMode(); + await dashboard.gotoDashboardLandingPage(); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/index.ts b/test/functional/apps/dashboard_elements/controls/index.ts new file mode 100644 index 0000000000000..a29834c848094 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/index.ts @@ -0,0 +1,54 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile, getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const security = getService('security'); + + const { dashboardControls, common, dashboard } = getPageObjects([ + 'dashboardControls', + 'dashboard', + 'common', + ]); + + async function setup() { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + + // enable the controls lab and navigate to the dashboard listing page to start + await common.navigateToApp('dashboard'); + await dashboardControls.enableControlsLab(); + await common.navigateToApp('dashboard'); + await dashboard.preserveCrossAppState(); + } + + async function teardown() { + await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); + await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); + } + + describe('Controls', function () { + before(setup); + after(teardown); + loadTestFile(require.resolve('./controls_callout')); + loadTestFile(require.resolve('./control_group_settings')); + loadTestFile(require.resolve('./options_list')); + loadTestFile(require.resolve('./control_group_chaining')); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts new file mode 100644 index 0000000000000..6272448a68f93 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -0,0 +1,369 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const queryBar = getService('queryBar'); + const pieChart = getService('pieChart'); + const filterBar = getService('filterBar'); + const testSubjects = getService('testSubjects'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const { dashboardControls, timePicker, common, dashboard, header } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'common', + 'header', + ]); + + describe('Dashboard options list integration', () => { + before(async () => { + await common.navigateToApp('dashboard'); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); + }); + + describe('Options List Control creation and editing experience', async () => { + it('can add a new options list control from a blank state', async () => { + await dashboardControls.createOptionsListControl({ fieldName: 'machine.os.raw' }); + expect(await dashboardControls.getControlsCount()).to.be(1); + }); + + it('can add a second options list control with a non-default data view', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + expect(await dashboardControls.getControlsCount()).to.be(2); + + // data views should be properly propagated from the control group to the dashboard + expect(await filterBar.getIndexPatterns()).to.be('logstash-*,animals-*'); + }); + + it('renames an existing control', async () => { + const secondId = (await dashboardControls.getAllControlIds())[1]; + + const newTitle = 'wow! Animal sounds?'; + await dashboardControls.editExistingControl(secondId); + await dashboardControls.controlEditorSetTitle(newTitle); + await dashboardControls.controlEditorSave(); + expect(await dashboardControls.doesControlTitleExist(newTitle)).to.be(true); + }); + + it('can change the data view and field of an existing options list', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.editExistingControl(firstId); + + await dashboardControls.optionsListEditorSetDataView('animals-*'); + await dashboardControls.optionsListEditorSetfield('animal.keyword'); + await dashboardControls.controlEditorSave(); + + // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view + await retry.try(async () => { + await testSubjects.click('addFilter'); + const indexPatternSelectExists = await testSubjects.exists('filterIndexPatternsSelect'); + await filterBar.ensureFieldEditorModalIsClosed(); + expect(indexPatternSelectExists).to.be(false); + }); + }); + + it('deletes an existing control', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + + await dashboardControls.removeExistingControl(firstId); + expect(await dashboardControls.getControlsCount()).to.be(1); + }); + + after(async () => { + await dashboardControls.clearAllControls(); + }); + }); + + describe('Interactions between options list and dashboard', async () => { + let controlId: string; + + const allAvailableOptions = [ + 'hiss', + 'ruff', + 'bark', + 'grrr', + 'meow', + 'growl', + 'grr', + 'bow ow ow', + ]; + + const ensureAvailableOptionsEql = async (expectation: string[], skipOpen?: boolean) => { + if (!skipOpen) await dashboardControls.optionsListOpenPopover(controlId); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql( + expectation + ); + }); + if (!skipOpen) await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }; + + before(async () => { + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + title: 'Animal Sounds', + }); + + controlId = (await dashboardControls.getAllControlIds())[0]; + }); + + describe('Applies query settings to controls', async () => { + it('Applies dashboard query to options list control', async () => { + await queryBar.setQuery('isDog : true '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await ensureAvailableOptionsEql(['ruff', 'bark', 'grrr', 'bow ow ow', 'grr']); + + await queryBar.setQuery(''); + await queryBar.submitQuery(); + + // using the query hides the time range. Clicking anywhere else shows it again. + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('Applies dashboard time range to options list control', async () => { + // set time range to time with no documents + await timePicker.setAbsoluteRange( + 'Jan 1, 2017 @ 00:00:00.000', + 'Jan 1, 2017 @ 00:00:00.000' + ); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await dashboardControls.optionsListOpenPopover(controlId); + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(0); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await timePicker.setDefaultDataRange(); + }); + + describe('dashboard filters', async () => { + before(async () => { + await filterBar.addFilter('sound.keyword', 'is one of', ['bark', 'bow ow ow', 'ruff']); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + }); + + it('Applies dashboard filters to options list control', async () => { + await ensureAvailableOptionsEql(['ruff', 'bark', 'bow ow ow']); + }); + + it('Does not apply disabled dashboard filters to options list control', async () => { + await filterBar.toggleFilterEnabled('sound.keyword'); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await ensureAvailableOptionsEql(allAvailableOptions); + + await filterBar.toggleFilterEnabled('sound.keyword'); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + }); + + it('Negated filters apply to options control', async () => { + await filterBar.toggleFilterNegated('sound.keyword'); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await ensureAvailableOptionsEql(['hiss', 'grrr', 'meow', 'growl', 'grr']); + }); + + after(async () => { + await filterBar.removeAllFilters(); + }); + }); + }); + + describe('Does not apply query settings to controls', async () => { + before(async () => { + await dashboardControls.updateAllQuerySyncSettings(false); + }); + + after(async () => { + await dashboardControls.updateAllQuerySyncSettings(true); + }); + + it('Does not apply query to options list control', async () => { + await queryBar.setQuery('isDog : true '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(allAvailableOptions); + await queryBar.setQuery(''); + await queryBar.submitQuery(); + }); + + it('Does not apply filters to options list control', async () => { + await filterBar.addFilter('sound.keyword', 'is one of', ['bark', 'bow ow ow', 'ruff']); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(allAvailableOptions); + await filterBar.removeAllFilters(); + }); + + it('Does not apply time range to options list control', async () => { + // set time range to time with no documents + await timePicker.setAbsoluteRange( + 'Jan 1, 2017 @ 00:00:00.000', + 'Jan 1, 2017 @ 00:00:00.000' + ); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(allAvailableOptions); + await timePicker.setDefaultDataRange(); + }); + }); + + describe('Selections made in control apply to dashboard', async () => { + it('Shows available options in options list', async () => { + await ensureAvailableOptionsEql(allAvailableOptions); + }); + + it('Can search options list for available options', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSearchForOption('meo'); + await ensureAvailableOptionsEql(['meow'], true); + await dashboardControls.optionsListPopoverClearSearch(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('Can select multiple available options', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('hiss'); + await dashboardControls.optionsListPopoverSelectOption('grr'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('Selected options appear in control', async () => { + const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); + expect(selectionString).to.be('hiss, grr'); + }); + + it('Applies options list control options to dashboard', async () => { + await retry.try(async () => { + expect(await pieChart.getPieSliceCount()).to.be(2); + }); + }); + + it('Applies options list control options to dashboard by default on open', async () => { + await dashboard.gotoDashboardLandingPage(); + await header.waitUntilLoadingHasFinished(); + await dashboard.clickUnsavedChangesContinueEditing('New Dashboard'); + await header.waitUntilLoadingHasFinished(); + expect(await pieChart.getPieSliceCount()).to.be(2); + + const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); + expect(selectionString).to.be('hiss, grr'); + }); + + after(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + }); + + describe('Options List dashboard validation', async () => { + before(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListPopoverSelectOption('bark'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + after(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await filterBar.removeAllFilters(); + }); + + it('Can mark selections invalid with Query', async () => { + await queryBar.setQuery('isDog : false '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql([ + 'hiss', + 'meow', + 'growl', + 'grr', + 'Ignored selection', + 'bark', + ]); + + // only valid selections are applied as filters. + expect(await pieChart.getPieSliceCount()).to.be(1); + }); + + it('can make invalid selections valid again if the parent filter changes', async () => { + await queryBar.setQuery(''); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(allAvailableOptions); + expect(await pieChart.getPieSliceCount()).to.be(2); + }); + + it('Can mark multiple selections invalid with Filter', async () => { + await filterBar.addFilter('sound.keyword', 'is', ['hiss']); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(['hiss', 'Ignored selections', 'meow', 'bark']); + + // only valid selections are applied as filters. + expect(await pieChart.getPieSliceCount()).to.be(1); + }); + }); + + describe('Options List dashboard no validation', async () => { + before(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListPopoverSelectOption('bark'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await dashboardControls.updateValidationSetting(false); + }); + + it('Does not mark selections invalid with Query', async () => { + await queryBar.setQuery('isDog : false '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(['hiss', 'meow', 'growl', 'grr']); + }); + + it('Does not mark multiple selections invalid with Filter', async () => { + await filterBar.addFilter('sound.keyword', 'is', ['hiss']); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(['hiss']); + }); + }); + + after(async () => { + await filterBar.removeAllFilters(); + await dashboardControls.clearAllControls(); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/index.ts b/test/functional/apps/dashboard_elements/index.ts index 4866754c3907b..059576389f32e 100644 --- a/test/functional/apps/dashboard_elements/index.ts +++ b/test/functional/apps/dashboard_elements/index.ts @@ -27,6 +27,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { this.tags('ciGroup10'); loadTestFile(require.resolve('./input_control_vis')); + loadTestFile(require.resolve('./controls')); loadTestFile(require.resolve('./_markdown_vis')); }); }); diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index 33053306243fe..c57c6d304e1e5 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -9,6 +9,7 @@ import expect from '@kbn/expect'; import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; import { OPTIONS_LIST_CONTROL, ControlWidth } from '../../../src/plugins/controls/common'; +import { ControlGroupChainingSystem } from '../../../src/plugins/controls/common/control_group/types'; import { FtrService } from '../ftr_provider_context'; @@ -63,6 +64,13 @@ export class DashboardPageControls extends FtrService { return allTitles.length; } + public async clearAllControls() { + const controlIds = await this.getAllControlIds(); + for (const controlId of controlIds) { + await this.removeExistingControl(controlId); + } + } + public async openCreateControlFlyout(type: string) { this.log.debug(`Opening flyout for ${type} control`); await this.testSubjects.click('dashboard-controls-menu-button'); @@ -119,6 +127,85 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.click('control-group-editor-save'); } + public async updateChainingSystem(chainingSystem: ControlGroupChainingSystem) { + this.log.debug(`Update control group chaining system to ${chainingSystem}`); + await this.openControlGroupSettingsFlyout(); + await this.testSubjects.existOrFail('control-group-chaining'); + // currently there are only two chaining systems, so a switch is used. + const switchStateToChainingSystem: { [key: string]: ControlGroupChainingSystem } = { + true: 'HIERARCHICAL', + false: 'NONE', + }; + + const switchState = await this.testSubjects.getAttribute('control-group-chaining', 'checked'); + if (chainingSystem !== switchStateToChainingSystem[switchState]) { + await this.testSubjects.click('control-group-chaining'); + } + await this.testSubjects.click('control-group-editor-save'); + } + + public async setSwitchState(goalState: boolean, subject: string) { + await this.testSubjects.existOrFail(subject); + const currentStateIsChecked = + (await this.testSubjects.getAttribute(subject, 'aria-checked')) === 'true'; + if (currentStateIsChecked !== goalState) { + await this.testSubjects.click(subject); + } + await this.retry.try(async () => { + const stateIsChecked = (await this.testSubjects.getAttribute(subject, 'checked')) === 'true'; + expect(stateIsChecked).to.be(goalState); + }); + } + + public async updateValidationSetting(validate: boolean) { + this.log.debug(`Update control group validation setting to ${validate}`); + await this.openControlGroupSettingsFlyout(); + await this.setSwitchState(validate, 'control-group-validate-selections'); + await this.testSubjects.click('control-group-editor-save'); + } + + public async updateAllQuerySyncSettings(querySync: boolean) { + this.log.debug(`Update all control group query sync settings to ${querySync}`); + await this.openControlGroupSettingsFlyout(); + await this.setSwitchState(querySync, 'control-group-query-sync'); + await this.testSubjects.click('control-group-editor-save'); + } + + public async ensureAdvancedQuerySyncIsOpened() { + const advancedAccordion = await this.testSubjects.find(`control-group-query-sync-advanced`); + const opened = await advancedAccordion.elementHasClass('euiAccordion-isOpen'); + if (!opened) { + await this.testSubjects.click(`control-group-query-sync-advanced`); + await this.retry.try(async () => { + expect(await advancedAccordion.elementHasClass('euiAccordion-isOpen')).to.be(true); + }); + } + } + + public async updateSyncTimeRangeAdvancedSetting(syncTimeRange: boolean) { + this.log.debug(`Update filter sync advanced setting to ${syncTimeRange}`); + await this.openControlGroupSettingsFlyout(); + await this.ensureAdvancedQuerySyncIsOpened(); + await this.setSwitchState(syncTimeRange, 'control-group-query-sync-time-range'); + await this.testSubjects.click('control-group-editor-save'); + } + + public async updateSyncQueryAdvancedSetting(syncQuery: boolean) { + this.log.debug(`Update filter sync advanced setting to ${syncQuery}`); + await this.openControlGroupSettingsFlyout(); + await this.ensureAdvancedQuerySyncIsOpened(); + await this.setSwitchState(syncQuery, 'control-group-query-sync-query'); + await this.testSubjects.click('control-group-editor-save'); + } + + public async updateSyncFilterAdvancedSetting(syncFilters: boolean) { + this.log.debug(`Update filter sync advanced setting to ${syncFilters}`); + await this.openControlGroupSettingsFlyout(); + await this.ensureAdvancedQuerySyncIsOpened(); + await this.setSwitchState(syncFilters, 'control-group-query-sync-filters'); + await this.testSubjects.click('control-group-editor-save'); + } + /* ----------------------------------------------------------- Individual controls functions ----------------------------------------------------------- */ diff --git a/x-pack/plugins/alerting/common/alert.ts b/x-pack/plugins/alerting/common/alert.ts index dd85fadb49878..1628abff7efc1 100644 --- a/x-pack/plugins/alerting/common/alert.ts +++ b/x-pack/plugins/alerting/common/alert.ts @@ -76,6 +76,7 @@ export interface AlertAggregations { alertExecutionStatus: { [status: string]: number }; ruleEnabledStatus: { enabled: number; disabled: number }; ruleMutedStatus: { muted: number; unmuted: number }; + ruleSnoozedStatus: { snoozed: number }; } export interface MappedParamsProperties { diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts index 81fb66ef5cf55..038e923f28f0c 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts @@ -57,6 +57,9 @@ describe('aggregateRulesRoute', () => { muted: 2, unmuted: 39, }, + ruleSnoozedStatus: { + snoozed: 4, + }, }; rulesClient.aggregate.mockResolvedValueOnce(aggregateResult); @@ -88,6 +91,9 @@ describe('aggregateRulesRoute', () => { "muted": 2, "unmuted": 39, }, + "rule_snoozed_status": Object { + "snoozed": 4, + }, }, } `); @@ -120,6 +126,9 @@ describe('aggregateRulesRoute', () => { muted: 2, unmuted: 39, }, + rule_snoozed_status: { + snoozed: 4, + }, }, }); }); diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts index ee05897848ecf..8c44f57b83789 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts @@ -49,12 +49,14 @@ const rewriteBodyRes: RewriteResponseCase = ({ alertExecutionStatus, ruleEnabledStatus, ruleMutedStatus, + ruleSnoozedStatus, ...rest }) => ({ ...rest, rule_execution_status: alertExecutionStatus, rule_enabled_status: ruleEnabledStatus, rule_muted_status: ruleMutedStatus, + rule_snoozed_status: ruleSnoozedStatus, }); export const aggregateRulesRoute = ( diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 666617dcf3fd8..5f5baf41affae 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -125,6 +125,13 @@ export interface RuleAggregation { doc_count: number; }>; }; + snoozed: { + buckets: Array<{ + key: number; + key_as_string: string; + doc_count: number; + }>; + }; } export interface ConstructorOptions { @@ -191,6 +198,7 @@ export interface AggregateResult { alertExecutionStatus: { [status: string]: number }; ruleEnabledStatus?: { enabled: number; disabled: number }; ruleMutedStatus?: { muted: number; unmuted: number }; + ruleSnoozedStatus?: { snoozed: number }; } export interface FindResult { @@ -368,19 +376,12 @@ export class RulesClient { await this.validateActions(ruleType, data.actions); - // Validate that schedule interval is not less than configured minimum + // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); - if (intervalInMs < this.minimumScheduleIntervalInMs) { - if (this.minimumScheduleInterval.enforce) { - throw Boom.badRequest( - `Error creating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` - ); - } else { - // just log warning but allow rule to be created - this.logger.warn( - `Rule schedule interval (${data.schedule.interval}) is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` - ); - } + if (intervalInMs < this.minimumScheduleIntervalInMs && this.minimumScheduleInterval.enforce) { + throw Boom.badRequest( + `Error creating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` + ); } // Extract saved object references for this rule @@ -472,6 +473,14 @@ export class RulesClient { }); createdAlert.attributes.scheduledTaskId = scheduledTask.id; } + + // Log warning if schedule interval is less than the minimum but we're not enforcing it + if (intervalInMs < this.minimumScheduleIntervalInMs && !this.minimumScheduleInterval.enforce) { + this.logger.warn( + `Rule schedule interval (${data.schedule.interval}) for "${createdAlert.attributes.alertTypeId}" rule type with ID "${createdAlert.id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` + ); + } + return this.getAlertFromRaw( createdAlert.id, createdAlert.attributes.alertTypeId, @@ -858,6 +867,7 @@ export class RulesClient { ); throw error; } + const { filter: authorizationFilter } = authorizationTuple; const resp = await this.unsecuredSavedObjectsClient.find({ ...options, @@ -878,6 +888,13 @@ export class RulesClient { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + snoozed: { + date_range: { + field: 'alert.attributes.snoozeEndTime', + format: 'strict_date_time', + ranges: [{ from: 'now' }], + }, + }, }, }); @@ -893,6 +910,7 @@ export class RulesClient { muted: 0, unmuted: 0, }, + ruleSnoozedStatus: { snoozed: 0 }, }; for (const key of RuleExecutionStatusValues) { @@ -934,6 +952,11 @@ export class RulesClient { unmuted: mutedBuckets.find((bucket) => bucket.key === 0)?.doc_count ?? 0, }; + const snoozedBuckets = resp.aggregations.snoozed.buckets; + ret.ruleSnoozedStatus = { + snoozed: snoozedBuckets.reduce((acc, bucket) => acc + bucket.doc_count, 0), + }; + return ret; } @@ -1117,19 +1140,12 @@ export class RulesClient { const validatedAlertTypeParams = validateRuleTypeParams(data.params, ruleType.validate?.params); await this.validateActions(ruleType, data.actions); - // Validate that schedule interval is not less than configured minimum + // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); - if (intervalInMs < this.minimumScheduleIntervalInMs) { - if (this.minimumScheduleInterval.enforce) { - throw Boom.badRequest( - `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` - ); - } else { - // just log warning but allow rule to be updated - this.logger.warn( - `Rule schedule interval (${data.schedule.interval}) is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` - ); - } + if (intervalInMs < this.minimumScheduleIntervalInMs && this.minimumScheduleInterval.enforce) { + throw Boom.badRequest( + `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` + ); } // Extract saved object references for this rule @@ -1192,6 +1208,13 @@ export class RulesClient { throw e; } + // Log warning if schedule interval is less than the minimum but we're not enforcing it + if (intervalInMs < this.minimumScheduleIntervalInMs && !this.minimumScheduleInterval.enforce) { + this.logger.warn( + `Rule schedule interval (${data.schedule.interval}) for "${ruleType.id}" rule type with ID "${id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` + ); + } + return this.getPartialRuleFromRaw( id, ruleType, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts index aa910f4203f46..af27decb73a2a 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts @@ -101,6 +101,17 @@ describe('aggregate()', () => { { key: 1, key_as_string: '1', doc_count: 3 }, ], }, + snoozed: { + buckets: [ + { + key: '2022-03-21T20:22:01.501Z-*', + format: 'strict_date_time', + from: 1.647894121501e12, + from_as_string: '2022-03-21T20:22:01.501Z', + doc_count: 2, + }, + ], + }, }, }); @@ -146,6 +157,9 @@ describe('aggregate()', () => { "muted": 3, "unmuted": 27, }, + "ruleSnoozedStatus": Object { + "snoozed": 2, + }, } `); expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -166,6 +180,13 @@ describe('aggregate()', () => { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + snoozed: { + date_range: { + field: 'alert.attributes.snoozeEndTime', + format: 'strict_date_time', + ranges: [{ from: 'now' }], + }, + }, }, }, ]); @@ -193,6 +214,13 @@ describe('aggregate()', () => { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + snoozed: { + date_range: { + field: 'alert.attributes.snoozeEndTime', + format: 'strict_date_time', + ranges: [{ from: 'now' }], + }, + }, }, }, ]); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index df0e806e5e798..91be42ecd9e1f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -2602,7 +2602,7 @@ describe('create()', () => { await rulesClient.create({ data }); expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( - `Rule schedule interval (1s) is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` + `Rule schedule interval (1s) for "123" rule type with ID "1" is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` ); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalled(); expect(taskManager.schedule).toHaveBeenCalled(); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index a087dfd436817..4bc0276a9ae1a 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -1947,7 +1947,7 @@ describe('update()', () => { }, }); expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( - `Rule schedule interval (1s) is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` + `Rule schedule interval (1s) for "myType" rule type with ID "1" is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` ); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index 04288fccf0a05..f2f021b81d76d 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -22,7 +22,7 @@ export enum AlertType { ErrorCount = 'apm.error_rate', // ErrorRate was renamed to ErrorCount but the key is kept as `error_rate` for backwards-compat. TransactionErrorRate = 'apm.transaction_error_rate', TransactionDuration = 'apm.transaction_duration', - TransactionDurationAnomaly = 'apm.transaction_duration_anomaly', + Anomaly = 'apm.anomaly', } export const THRESHOLD_MET_GROUP_ID = 'threshold_met'; @@ -127,7 +127,7 @@ export function formatTransactionErrorRateReason({ }); } -export function formatTransactionDurationAnomalyReason({ +export function formatAnomalyReason({ serviceName, severityLevel, measured, @@ -188,9 +188,9 @@ export const ALERT_TYPES_CONFIG: Record< producer: APM_SERVER_FEATURE_ID, isExportable: true, }, - [AlertType.TransactionDurationAnomaly]: { - name: i18n.translate('xpack.apm.transactionDurationAnomalyAlert.name', { - defaultMessage: 'Latency anomaly', + [AlertType.Anomaly]: { + name: i18n.translate('xpack.apm.anomalyAlert.name', { + defaultMessage: 'Anomaly', }), actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: THRESHOLD_MET_GROUP_ID, diff --git a/x-pack/plugins/apm/common/utils/formatters/alert_url.ts b/x-pack/plugins/apm/common/utils/formatters/alert_url.ts index a88f69b4ef5c7..982da4803cb57 100644 --- a/x-pack/plugins/apm/common/utils/formatters/alert_url.ts +++ b/x-pack/plugins/apm/common/utils/formatters/alert_url.ts @@ -27,7 +27,7 @@ export const getAlertUrlErrorCount = ( environment: serviceEnv ?? ENVIRONMENT_ALL.value, }, }); -// This formatter is for TransactionDuration, TransactionErrorRate, and TransactionDurationAnomaly. +// This formatter is for TransactionDuration, TransactionErrorRate, and Anomaly. export const getAlertUrlTransaction = ( serviceName: string, serviceEnv: string | undefined, diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts index 692165f2b2ff5..69ed7d73c3115 100644 --- a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -147,13 +147,11 @@ export function registerApmAlerts( }); observabilityRuleTypeRegistry.register({ - id: AlertType.TransactionDurationAnomaly, - description: i18n.translate( - 'xpack.apm.alertTypes.transactionDurationAnomaly.description', - { - defaultMessage: 'Alert when the latency of a service is abnormal.', - } - ), + id: AlertType.Anomaly, + description: i18n.translate('xpack.apm.alertTypes.anomaly.description', { + defaultMessage: + 'Alert when either the latency, throughput, or failed transaction rate of a service is anomalous.', + }), format: ({ fields }) => ({ reason: fields[ALERT_REASON]!, link: getAlertUrlTransaction( diff --git a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx index f988917515fbb..164a413a548ee 100644 --- a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx @@ -30,7 +30,7 @@ const transactionErrorRateLabel = i18n.translate( { defaultMessage: 'Failed transaction rate' } ); const errorCountLabel = i18n.translate('xpack.apm.home.alertsMenu.errorCount', { - defaultMessage: 'Error count', + defaultMessage: ' Create error count rule', }); const createThresholdAlertLabel = i18n.translate( 'xpack.apm.home.alertsMenu.createThresholdAlert', @@ -41,11 +41,7 @@ const createAnomalyAlertAlertLabel = i18n.translate( { defaultMessage: 'Create anomaly rule' } ); -const CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID = - 'create_transaction_duration_panel'; -const CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID = - 'create_transaction_error_rate_panel'; -const CREATE_ERROR_COUNT_ALERT_PANEL_ID = 'create_error_count_panel'; +const CREATE_THRESHOLD_PANEL_ID = 'create_threshold_panel'; interface Props { basePath: IBasePath; @@ -86,16 +82,26 @@ export function AlertingPopoverAndFlyout({ ...(canSaveAlerts ? [ { - name: transactionDurationLabel, - panel: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID, - }, - { - name: transactionErrorRateLabel, - panel: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID, + name: createThresholdAlertLabel, + panel: CREATE_THRESHOLD_PANEL_ID, }, + ...(canReadAnomalies + ? [ + { + name: createAnomalyAlertAlertLabel, + onClick: () => { + setAlertType(AlertType.Anomaly); + setPopoverOpen(false); + }, + }, + ] + : []), { name: errorCountLabel, - panel: CREATE_ERROR_COUNT_ALERT_PANEL_ID, + onClick: () => { + setAlertType(AlertType.ErrorCount); + setPopoverOpen(false); + }, }, ] : []), @@ -114,16 +120,16 @@ export function AlertingPopoverAndFlyout({ ], }, - // latency panel + // Threshold panel { - id: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID, - title: transactionDurationLabel, + id: CREATE_THRESHOLD_PANEL_ID, + title: createThresholdAlertLabel, items: [ - // threshold alerts + // Latency ...(includeTransactionDuration ? [ { - name: createThresholdAlertLabel, + name: transactionDurationLabel, onClick: () => { setAlertType(AlertType.TransactionDuration); setPopoverOpen(false); @@ -131,30 +137,10 @@ export function AlertingPopoverAndFlyout({ }, ] : []), - - // anomaly alerts - ...(canReadAnomalies - ? [ - { - name: createAnomalyAlertAlertLabel, - onClick: () => { - setAlertType(AlertType.TransactionDurationAnomaly); - setPopoverOpen(false); - }, - }, - ] - : []), - ], - }, - - // Failed transactions panel - { - id: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID, - title: transactionErrorRateLabel, - items: [ - // threshold alerts + // Throughput *** TO BE ADDED *** + // Failed transactions rate { - name: createThresholdAlertLabel, + name: transactionErrorRateLabel, onClick: () => { setAlertType(AlertType.TransactionErrorRate); setPopoverOpen(false); @@ -162,21 +148,6 @@ export function AlertingPopoverAndFlyout({ }, ], }, - - // error alerts panel - { - id: CREATE_ERROR_COUNT_ALERT_PANEL_ID, - title: errorCountLabel, - items: [ - { - name: createThresholdAlertLabel, - onClick: () => { - setAlertType(AlertType.ErrorCount); - setPopoverOpen(false); - }, - }, - ], - }, ]; return ( diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts index 68e29f7afcc79..d69740c51d04d 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts @@ -61,6 +61,7 @@ describe('APMEventClient', () => { apm: { events: [], }, + body: { size: 0 }, }); return res.ok({ body: 'ok' }); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index fdf023e197b7c..4b8f63e33799c 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -40,6 +40,9 @@ export type APMEventESSearchRequest = Omit & { events: ProcessorEvent[]; includeLegacyData?: boolean; }; + body: { + size: number; + }; }; export type APMEventESTermsEnumRequest = Omit & { diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts index d3f0fca0bb259..3b17c656b06e3 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts @@ -14,7 +14,10 @@ describe('unpackProcessorEvents', () => { beforeEach(() => { const request = { apm: { events: ['transaction', 'error'] }, - body: { query: { bool: { filter: [{ terms: { foo: 'bar' } }] } } }, + body: { + size: 0, + query: { bool: { filter: [{ terms: { foo: 'bar' } }] } }, + }, } as APMEventESSearchRequest; const indices = { diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 6d3789837d2d9..ae47abb01942e 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -108,7 +108,7 @@ describe('setupRequest', () => { const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search('foo', { apm: { events: [ProcessorEvent.transaction] }, - body: { foo: 'bar' }, + body: { size: 10 }, }); expect( @@ -117,7 +117,7 @@ describe('setupRequest', () => { { index: ['apm-*'], body: { - foo: 'bar', + size: 10, query: { bool: { filter: [{ terms: { 'processor.event': ['transaction'] } }], @@ -172,6 +172,7 @@ describe('with includeFrozen=false', () => { apm: { events: [], }, + body: { size: 10 }, }); const params = @@ -193,6 +194,7 @@ describe('with includeFrozen=true', () => { await apmEventClient.search('foo', { apm: { events: [] }, + body: { size: 10 }, }); const params = diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap b/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap index 56d735b5df115..06e80110b6f20 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap @@ -31,6 +31,7 @@ Object { ], }, }, + "size": 1, }, "terminate_after": 1, } @@ -55,6 +56,7 @@ Object { ], }, }, + "size": 1, }, "terminate_after": 1, } @@ -82,6 +84,7 @@ Array [ ], }, }, + "size": 1, }, "terminate_after": 1, }, @@ -100,6 +103,7 @@ Array [ "filter": Array [], }, }, + "size": 0, }, "terminate_after": 1, }, diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts b/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts index 12c47936374e1..a28fe1ad1ecea 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts +++ b/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts @@ -64,6 +64,7 @@ async function getHasTransactions({ events: [ProcessorEvent.transaction], }, body: { + size: 0, query: { bool: { filter: [ diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts b/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts index 577a7544d93ea..573cb0a3cf6b4 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts @@ -33,6 +33,7 @@ export async function getHasAggregatedTransactions({ events: [ProcessorEvent.metric], }, body: { + size: 1, query: { bool: { filter: [ diff --git a/x-pack/plugins/apm/server/projections/typings.ts b/x-pack/plugins/apm/server/projections/typings.ts index d252fd311b4fe..5558fba4cde2a 100644 --- a/x-pack/plugins/apm/server/projections/typings.ts +++ b/x-pack/plugins/apm/server/projections/typings.ts @@ -11,8 +11,9 @@ import { APMEventESSearchRequest } from '../lib/helpers/create_es_client/create_ export type Projection = Omit & { body: Omit< Required['body'], - 'aggs' | 'aggregations' + 'aggs' | 'aggregations' | 'size' > & { + size?: number; aggs?: { [key: string]: { terms: AggregationOptionsByType['terms'] & { field: string }; diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.test.ts similarity index 93% rename from x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts rename to x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.test.ts index 2bb8530ca03f6..2f4245c89694a 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.test.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; +import { registerAnomalyAlertType } from './register_anomaly_alert_type'; import { ANOMALY_SEVERITY } from '../../../common/ml_constants'; import { MlPluginSetup } from '../../../../ml/server'; import * as GetServiceAnomalies from '../service_map/get_service_anomalies'; @@ -19,7 +19,7 @@ describe('Transaction duration anomaly alert', () => { it('ml is not defined', async () => { const { services, dependencies, executor } = createRuleTypeMocks(); - registerTransactionDurationAnomalyAlertType({ + registerAnomalyAlertType({ ...dependencies, ml: undefined, }); @@ -47,7 +47,7 @@ describe('Transaction duration anomaly alert', () => { anomalyDetectorsProvider: jest.fn(), } as unknown as MlPluginSetup; - registerTransactionDurationAnomalyAlertType({ + registerAnomalyAlertType({ ...dependencies, ml, }); @@ -98,7 +98,7 @@ describe('Transaction duration anomaly alert', () => { anomalyDetectorsProvider: jest.fn(), } as unknown as MlPluginSetup; - registerTransactionDurationAnomalyAlertType({ + registerAnomalyAlertType({ ...dependencies, ml, }); @@ -174,7 +174,7 @@ describe('Transaction duration anomaly alert', () => { anomalyDetectorsProvider: jest.fn(), } as unknown as MlPluginSetup; - registerTransactionDurationAnomalyAlertType({ + registerAnomalyAlertType({ ...dependencies, ml, }); @@ -190,7 +190,7 @@ describe('Transaction duration anomaly alert', () => { expect(services.alertFactory.create).toHaveBeenCalledTimes(1); expect(services.alertFactory.create).toHaveBeenCalledWith( - 'apm.transaction_duration_anomaly_foo_development_type-foo' + 'apm.anomaly_foo_development_type-foo' ); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts similarity index 94% rename from x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts rename to x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts index 64f06c9f638f1..04d1fb775cea0 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts @@ -32,7 +32,7 @@ import { AlertType, ALERT_TYPES_CONFIG, ANOMALY_ALERT_SEVERITY_TYPES, - formatTransactionDurationAnomalyReason, + formatAnomalyReason, } from '../../../common/alert_types'; import { getMLJobs } from '../service_map/get_service_anomalies'; import { apmActionVariables } from './action_variables'; @@ -57,10 +57,9 @@ const paramsSchema = schema.object({ ]), }); -const alertTypeConfig = - ALERT_TYPES_CONFIG[AlertType.TransactionDurationAnomaly]; +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.Anomaly]; -export function registerTransactionDurationAnomalyAlertType({ +export function registerAnomalyAlertType({ logger, ruleDataClient, alerting, @@ -74,7 +73,7 @@ export function registerTransactionDurationAnomalyAlertType({ alerting.registerType( createLifecycleRuleType({ - id: AlertType.TransactionDurationAnomaly, + id: AlertType.Anomaly, name: alertTypeConfig.name, actionGroups: alertTypeConfig.actionGroups, defaultActionGroupId: alertTypeConfig.defaultActionGroupId, @@ -215,7 +214,7 @@ export function registerTransactionDurationAnomalyAlertType({ compact(anomalies).forEach((anomaly) => { const { serviceName, environment, transactionType, score } = anomaly; const severityLevel = getSeverity(score); - const reasonMessage = formatTransactionDurationAnomalyReason({ + const reasonMessage = formatAnomalyReason({ measured: score, serviceName, severityLevel, @@ -237,12 +236,7 @@ export function registerTransactionDurationAnomalyAlertType({ : relativeViewInAppUrl; services .alertWithLifecycle({ - id: [ - AlertType.TransactionDurationAnomaly, - serviceName, - environment, - transactionType, - ] + id: [AlertType.Anomaly, serviceName, environment, transactionType] .filter((name) => name) .join('_'), fields: { diff --git a/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts b/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts index 4556abfea1ee5..dfe0310e919b4 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts @@ -10,7 +10,7 @@ import { IBasePath, Logger } from 'kibana/server'; import { PluginSetupContract as AlertingPluginSetupContract } from '../../../../alerting/server'; import { IRuleDataClient } from '../../../../rule_registry/server'; import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type'; -import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; +import { registerAnomalyAlertType } from './register_anomaly_alert_type'; import { registerErrorCountAlertType } from './register_error_count_alert_type'; import { APMConfig } from '../..'; import { MlPluginSetup } from '../../../../ml/server'; @@ -27,7 +27,7 @@ export interface RegisterRuleDependencies { export function registerApmAlerts(dependencies: RegisterRuleDependencies) { registerTransactionDurationAlertType(dependencies); - registerTransactionDurationAnomalyAlertType(dependencies); + registerAnomalyAlertType(dependencies); registerErrorCountAlertType(dependencies); registerTransactionErrorRateAlertType(dependencies); } diff --git a/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts b/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts index 521a846c3e1df..d8e4cf7af0bc5 100644 --- a/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts +++ b/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts @@ -64,7 +64,7 @@ export async function getOverallLatencyDistribution( { // TODO: add support for metrics apm: { events: [ProcessorEvent.transaction] }, - body: histogramIntervalRequestBody, + body: { size: 0, ...histogramIntervalRequestBody }, } )) as { aggregations?: { @@ -101,7 +101,7 @@ export async function getOverallLatencyDistribution( { // TODO: add support for metrics apm: { events: [ProcessorEvent.transaction] }, - body: transactionDurationRangesRequestBody, + body: { size: 0, ...transactionDurationRangesRequestBody }, } )) as { aggregations?: { diff --git a/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts b/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts index 3961b1a2ca603..c40834919f7f5 100644 --- a/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts +++ b/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts @@ -31,7 +31,7 @@ export async function getPercentileThresholdValue( { // TODO: add support for metrics apm: { events: [ProcessorEvent.transaction] }, - body: transactionDurationPercentilesRequestBody, + body: { size: 0, ...transactionDurationPercentilesRequestBody }, } )) as { aggregations?: { diff --git a/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts b/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts index 009d974e33721..3713b4faa73d9 100644 --- a/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts +++ b/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts @@ -141,6 +141,7 @@ function getProfilesWithStacks({ events: [ProcessorEvent.profile], }, body: { + size: 0, query: { bool: { filter, diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap b/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap index 921129cf2c1da..06011abc193c5 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap +++ b/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap @@ -42,8 +42,8 @@ Object { ], }, }, + "size": 1, }, - "size": 1, "terminate_after": 1, } `; @@ -61,8 +61,8 @@ Object { "filter": Array [], }, }, + "size": 1, }, - "size": 1, "terminate_after": 1, } `; diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts b/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts index 88d2ae9f339ac..d4e21f219f372 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts @@ -36,8 +36,8 @@ export async function getTransaction({ apm: { events: [ProcessorEvent.transaction as const], }, - size: 1, body: { + size: 1, query: { bool: { filter: esFilters, diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 5c349a65dd869..10005b2c87bce 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -257,13 +257,22 @@ export const LINK_APPROPRIATE_LICENSE = i18n.translate('xpack.cases.common.appro export const CASE_SUCCESS_TOAST = (title: string) => i18n.translate('xpack.cases.actions.caseSuccessToast', { + values: { title }, + defaultMessage: '{title} has been updated', + }); + +export const CASE_ALERT_SUCCESS_TOAST = (title: string) => + i18n.translate('xpack.cases.actions.caseAlertSuccessToast', { values: { title }, defaultMessage: 'An alert has been added to "{title}"', }); -export const CASE_SUCCESS_SYNC_TEXT = i18n.translate('xpack.cases.actions.caseSuccessSyncText', { - defaultMessage: 'Alerts in this case have their status synched with the case status', -}); +export const CASE_ALERT_SUCCESS_SYNC_TEXT = i18n.translate( + 'xpack.cases.actions.caseAlertSuccessSyncText', + { + defaultMessage: 'Alerts in this case have their status synched with the case status', + } +); export const VIEW_CASE = i18n.translate('xpack.cases.actions.viewCase', { defaultMessage: 'View Case', diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx index 9bd6a6675a5c1..517d1cfdd77b1 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx @@ -9,33 +9,97 @@ import { renderHook } from '@testing-library/react-hooks'; import { useToasts } from '../common/lib/kibana'; import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../common/mock'; import { CaseToastSuccessContent, useCasesToast } from './use_cases_toast'; -import { mockCase } from '../containers/mock'; +import { alertComment, basicComment, mockCase } from '../containers/mock'; import React from 'react'; import userEvent from '@testing-library/user-event'; +import { SupportedCaseAttachment } from '../types'; jest.mock('../common/lib/kibana'); const useToastsMock = useToasts as jest.Mock; describe('Use cases toast hook', () => { + const successMock = jest.fn(); + + function validateTitle(title: string) { + const mockParams = successMock.mock.calls[0][0]; + const el = document.createElement('div'); + mockParams.title(el); + expect(el).toHaveTextContent(title); + } + + function validateContent(content: string) { + const mockParams = successMock.mock.calls[0][0]; + const el = document.createElement('div'); + mockParams.text(el); + expect(el).toHaveTextContent(content); + } + + useToastsMock.mockImplementation(() => { + return { + addSuccess: successMock, + }; + }); + + beforeEach(() => { + successMock.mockClear(); + }); + describe('Toast hook', () => { - const successMock = jest.fn(); - useToastsMock.mockImplementation(() => { - return { - addSuccess: successMock, - }; - }); - it('should create a success tost when invoked with a case', () => { + it('should create a success toast when invoked with a case', () => { const { result } = renderHook( () => { return useCasesToast(); }, { wrapper: TestProviders } ); - result.current.showSuccessAttach(mockCase); + result.current.showSuccessAttach({ + theCase: mockCase, + }); expect(successMock).toHaveBeenCalled(); }); }); + + describe('toast title', () => { + it('should create a success toast when invoked with a case and a custom title', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ theCase: mockCase, title: 'Custom title' }); + validateTitle('Custom title'); + }); + + it('should display the alert sync title when called with an alert attachment ', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ + theCase: mockCase, + attachments: [alertComment as SupportedCaseAttachment], + }); + validateTitle('An alert has been added to "Another horrible breach!!'); + }); + + it('should display a generic title when called with a non-alert attachament', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ + theCase: mockCase, + attachments: [basicComment as SupportedCaseAttachment], + }); + validateTitle('Another horrible breach!! has been updated'); + }); + }); describe('Toast content', () => { let appMockRender: AppMockRenderer; const onViewCaseClick = jest.fn(); @@ -44,20 +108,57 @@ describe('Use cases toast hook', () => { onViewCaseClick.mockReset(); }); - it('renders a correct successfull message with synced alerts', () => { - const result = appMockRender.render( - + it('should create a success toast when invoked with a case and a custom content', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } ); - expect(result.getByTestId('toaster-content-sync-text')).toHaveTextContent( - 'Alerts in this case have their status synched with the case status' + result.current.showSuccessAttach({ theCase: mockCase, content: 'Custom content' }); + validateContent('Custom content'); + }); + + it('renders an alert-specific content when called with an alert attachment and sync on', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ + theCase: mockCase, + attachments: [alertComment as SupportedCaseAttachment], + }); + validateContent('Alerts in this case have their status synched with the case status'); + }); + + it('renders empty content when called with an alert attachment and sync off', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ + theCase: { ...mockCase, settings: { ...mockCase.settings, syncAlerts: false } }, + attachments: [alertComment as SupportedCaseAttachment], + }); + validateContent('View Case'); + }); + + it('renders a correct successful message content', () => { + const result = appMockRender.render( + ); + expect(result.getByTestId('toaster-content-sync-text')).toHaveTextContent('my content'); expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View Case'); expect(onViewCaseClick).not.toHaveBeenCalled(); }); - it('renders a correct successfull message with not synced alerts', () => { + it('renders a correct successful message without content', () => { const result = appMockRender.render( - + ); expect(result.queryByTestId('toaster-content-sync-text')).toBeFalsy(); expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View Case'); @@ -66,7 +167,7 @@ describe('Use cases toast hook', () => { it('Calls the onViewCaseClick when clicked', () => { const result = appMockRender.render( - + ); userEvent.click(result.getByTestId('toaster-content-case-view-link')); expect(onViewCaseClick).toHaveBeenCalled(); diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.tsx index 98cc7fa1d8faa..d02f792d601cf 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.tsx @@ -9,10 +9,16 @@ import { EuiButtonEmpty, EuiText } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { Case } from '../../common'; +import { Case, CommentType } from '../../common'; import { useToasts } from '../common/lib/kibana'; import { useCaseViewNavigation } from '../common/navigation'; -import { CASE_SUCCESS_SYNC_TEXT, CASE_SUCCESS_TOAST, VIEW_CASE } from './translations'; +import { CaseAttachments } from '../types'; +import { + CASE_ALERT_SUCCESS_SYNC_TEXT, + CASE_ALERT_SUCCESS_TOAST, + CASE_SUCCESS_TOAST, + VIEW_CASE, +} from './translations'; const LINE_CLAMP = 3; const Title = styled.span` @@ -28,46 +34,101 @@ const EuiTextStyled = styled(EuiText)` `} `; +function getToastTitle({ + theCase, + title, + attachments, +}: { + theCase: Case; + title?: string; + attachments?: CaseAttachments; +}): string { + if (title !== undefined) { + return title; + } + if (attachments !== undefined) { + for (const attachment of attachments) { + if (attachment.type === CommentType.alert) { + return CASE_ALERT_SUCCESS_TOAST(theCase.title); + } + } + } + return CASE_SUCCESS_TOAST(theCase.title); +} + +function getToastContent({ + theCase, + content, + attachments, +}: { + theCase: Case; + content?: string; + attachments?: CaseAttachments; +}): string | undefined { + if (content !== undefined) { + return content; + } + if (attachments !== undefined) { + for (const attachment of attachments) { + if (attachment.type === CommentType.alert && theCase.settings.syncAlerts) { + return CASE_ALERT_SUCCESS_SYNC_TEXT; + } + } + } + return undefined; +} + export const useCasesToast = () => { const { navigateToCaseView } = useCaseViewNavigation(); const toasts = useToasts(); return { - showSuccessAttach: (theCase: Case) => { + showSuccessAttach: ({ + theCase, + attachments, + title, + content, + }: { + theCase: Case; + attachments?: CaseAttachments; + title?: string; + content?: string; + }) => { const onViewCaseClick = () => { navigateToCaseView({ detailName: theCase.id, }); }; + const renderTitle = getToastTitle({ theCase, title, attachments }); + const renderContent = getToastContent({ theCase, content, attachments }); + return toasts.addSuccess({ color: 'success', iconType: 'check', - title: toMountPoint({CASE_SUCCESS_TOAST(theCase.title)}), + title: toMountPoint({renderTitle}), text: toMountPoint( - + ), }); }, }; }; + export const CaseToastSuccessContent = ({ - syncAlerts, onViewCaseClick, + content, }: { - syncAlerts: boolean; onViewCaseClick: () => void; + content?: string; }) => { return ( <> - {syncAlerts && ( + {content !== undefined ? ( - {CASE_SUCCESS_SYNC_TEXT} + {content} - )} + ) : null} { expect(dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: CasesContextStoreActionsList.OPEN_ADD_TO_CASE_MODAL, + payload: expect.objectContaining({ + hiddenStatuses: [CaseStatuses.closed, StatusAll], + }), }) ); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx index 5341f5be4183d..5d30c95227e2b 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx @@ -6,6 +6,7 @@ */ import { useCallback } from 'react'; +import { CaseStatuses, StatusAll } from '../../../../common'; import { AllCasesSelectorModalProps } from '.'; import { useCasesToast } from '../../../common/use_cases_toast'; import { Case } from '../../../containers/types'; @@ -13,7 +14,12 @@ import { CasesContextStoreActionsList } from '../../cases_context/cases_context_ import { useCasesContext } from '../../cases_context/use_cases_context'; import { useCasesAddToNewCaseFlyout } from '../../create/flyout/use_cases_add_to_new_case_flyout'; -export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps) => { +type AddToExistingFlyoutProps = AllCasesSelectorModalProps & { + toastTitle?: string; + toastContent?: string; +}; + +export const useCasesAddToExistingCaseModal = (props: AddToExistingFlyoutProps) => { const createNewCaseFlyout = useCasesAddToNewCaseFlyout({ attachments: props.attachments, onClose: props.onClose, @@ -24,6 +30,8 @@ export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps return props.onRowClick(theCase); } }, + toastTitle: props.toastTitle, + toastContent: props.toastContent, }); const { dispatch } = useCasesContext(); const casesToasts = useCasesToast(); @@ -44,6 +52,7 @@ export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps type: CasesContextStoreActionsList.OPEN_ADD_TO_CASE_MODAL, payload: { ...props, + hiddenStatuses: [CaseStatuses.closed, StatusAll], onRowClick: (theCase?: Case) => { // when the case is undefined in the modal // the user clicked "create new case" @@ -51,7 +60,12 @@ export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps closeModal(); createNewCaseFlyout.open(); } else { - casesToasts.showSuccessAttach(theCase); + casesToasts.showSuccessAttach({ + theCase, + attachments: props.attachments, + title: props.toastTitle, + content: props.toastContent, + }); if (props.onRowClick) { props.onRowClick(theCase); } diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx index 5422ab9be995d..c1c0793fe2340 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx @@ -12,7 +12,12 @@ import { CasesContextStoreActionsList } from '../../cases_context/cases_context_ import { useCasesContext } from '../../cases_context/use_cases_context'; import { CreateCaseFlyoutProps } from './create_case_flyout'; -export const useCasesAddToNewCaseFlyout = (props: CreateCaseFlyoutProps) => { +type AddToNewCaseFlyoutProps = CreateCaseFlyoutProps & { + toastTitle?: string; + toastContent?: string; +}; + +export const useCasesAddToNewCaseFlyout = (props: AddToNewCaseFlyoutProps) => { const { dispatch } = useCasesContext(); const casesToasts = useCasesToast(); @@ -35,7 +40,12 @@ export const useCasesAddToNewCaseFlyout = (props: CreateCaseFlyoutProps) => { }, onSuccess: async (theCase: Case) => { if (theCase) { - casesToasts.showSuccessAttach(theCase); + casesToasts.showSuccessAttach({ + theCase, + attachments: props.attachments, + title: props.toastTitle, + content: props.toastContent, + }); } if (props.onSuccess) { return props.onSuccess(theCase); diff --git a/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts new file mode 100644 index 0000000000000..e6c7740f87fd3 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema as rt, TypeOf } from '@kbn/config-schema'; + +const cspRuleTemplateSchema = rt.object({ + name: rt.string(), + description: rt.string(), + rationale: rt.string(), + impact: rt.string(), + default_value: rt.string(), + remediation: rt.string(), + benchmark: rt.object({ name: rt.string(), version: rt.string() }), + severity: rt.string(), + benchmark_rule_id: rt.string(), + rego_rule_id: rt.string(), + tags: rt.arrayOf(rt.string()), +}); +export const cloudSecurityPostureRuleTemplateSavedObjectType = 'csp-rule-template'; +export type CloudSecurityPostureRuleTemplateSchema = TypeOf; diff --git a/x-pack/plugins/cloud_security_posture/server/config.ts b/x-pack/plugins/cloud_security_posture/server/config.ts index 9c9ff926a2c38..e40adadc55e98 100644 --- a/x-pack/plugins/cloud_security_posture/server/config.ts +++ b/x-pack/plugins/cloud_security_posture/server/config.ts @@ -11,7 +11,6 @@ import type { PluginConfigDescriptor } from 'kibana/server'; const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), }); - type CloudSecurityPostureConfig = TypeOf; export const config: PluginConfigDescriptor = { diff --git a/x-pack/plugins/cloud_security_posture/server/index.ts b/x-pack/plugins/cloud_security_posture/server/index.ts index f790ac5256ff8..82f2872a859f7 100755 --- a/x-pack/plugins/cloud_security_posture/server/index.ts +++ b/x-pack/plugins/cloud_security_posture/server/index.ts @@ -8,6 +8,7 @@ import type { PluginInitializerContext } from '../../../../src/core/server'; import { CspPlugin } from './plugin'; export type { CspServerPluginSetup, CspServerPluginStart } from './types'; +export type { cspRuleTemplateAssetType } from './saved_objects/csp_rule_template'; export const plugin = (initializerContext: PluginInitializerContext) => new CspPlugin(initializerContext); diff --git a/x-pack/plugins/cloud_security_posture/server/plugin.ts b/x-pack/plugins/cloud_security_posture/server/plugin.ts index f2f81ed608ba4..386eb2373ad63 100755 --- a/x-pack/plugins/cloud_security_posture/server/plugin.ts +++ b/x-pack/plugins/cloud_security_posture/server/plugin.ts @@ -18,10 +18,12 @@ import type { CspServerPluginStart, CspServerPluginSetupDeps, CspServerPluginStartDeps, + CspRequestHandlerContext, } from './types'; import { defineRoutes } from './routes'; -import { cspRuleAssetType } from './saved_objects/cis_1_4_1/csp_rule_type'; -import { initializeCspRules } from './saved_objects/cis_1_4_1/initialize_rules'; +import { cspRuleTemplateAssetType } from './saved_objects/csp_rule_template'; +import { cspRuleAssetType } from './saved_objects/csp_rule_type'; +import { initializeCspRules } from './saved_objects/initialize_rules'; import { initializeCspTransformsIndices } from './create_indices/create_transforms_indices'; export interface CspAppContext { @@ -54,8 +56,9 @@ export class CspPlugin }; core.savedObjects.registerType(cspRuleAssetType); + core.savedObjects.registerType(cspRuleTemplateAssetType); - const router = core.http.createRouter(); + const router = core.http.createRouter(); // Register server side APIs defineRoutes(router, cspAppContext); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts index 8d33d3db189d3..f6363794213ac 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts @@ -4,7 +4,18 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { httpServiceMock, loggingSystemMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { + httpServerMock, + httpServiceMock, + loggingSystemMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; +import { + ElasticsearchClientMock, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from 'src/core/server/elasticsearch/client/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { KibanaRequest } from 'src/core/server/http/router/request'; import { defineGetBenchmarksRoute, benchmarksInputSchema, @@ -14,6 +25,7 @@ import { getAgentPolicies, createBenchmarkEntry, } from './benchmarks'; + import { SavedObjectsClientContract } from 'src/core/server'; import { createMockAgentPolicyService, @@ -25,6 +37,17 @@ import { AgentPolicy } from '../../../../fleet/common'; import { CspAppService } from '../../lib/csp_app_services'; import { CspAppContext } from '../../plugin'; +export const getMockCspContext = (mockEsClient: ElasticsearchClientMock): KibanaRequest => { + return { + core: { + elasticsearch: { + client: { asCurrentUser: mockEsClient }, + }, + }, + fleet: { authz: { fleet: { all: false } } }, + } as unknown as KibanaRequest; +}; + function createMockAgentPolicy(props: Partial = {}): AgentPolicy { return { id: 'some-uuid1', @@ -66,6 +89,54 @@ describe('benchmarks API', () => { expect(config.path).toEqual('/api/csp/benchmarks'); }); + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetBenchmarksRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetBenchmarksRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: false } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(1); + }); + describe('test input schema', () => { it('expect to find default values', async () => { const validatedQuery = benchmarksInputSchema.validate({}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts index 1e6eadb0c77f6..366fcd9e409e9 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts @@ -5,7 +5,7 @@ * 2.0. */ import { uniq, map } from 'lodash'; -import type { IRouter, SavedObjectsClientContract } from 'src/core/server'; +import type { SavedObjectsClientContract } from 'src/core/server'; import { schema as rt, TypeOf } from '@kbn/config-schema'; import { transformError } from '@kbn/securitysolution-es-utils'; import type { @@ -23,6 +23,7 @@ import { BENCHMARKS_ROUTE_PATH, CIS_KUBERNETES_PACKAGE_NAME } from '../../../com import { CspAppContext } from '../../plugin'; import type { Benchmark } from '../../../common/types'; import { isNonNullable } from '../../../common/utils/helpers'; +import { CspRouter } from '../../types'; type BenchmarksQuerySchema = TypeOf; @@ -132,13 +133,17 @@ const createBenchmarks = ( .filter(isNonNullable); }); -export const defineGetBenchmarksRoute = (router: IRouter, cspContext: CspAppContext): void => +export const defineGetBenchmarksRoute = (router: CspRouter, cspContext: CspAppContext): void => router.get( { path: BENCHMARKS_ROUTE_PATH, validate: { query: benchmarksInputSchema }, }, async (context, request, response) => { + if (!context.fleet.authz.fleet.all) { + return response.forbidden(); + } + try { const soClient = context.core.savedObjects.client; const { query } = request; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.test.ts new file mode 100644 index 0000000000000..95addd9c055de --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { httpServerMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import // eslint-disable-next-line @kbn/eslint/no-restricted-paths +'src/core/server/elasticsearch/client/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { KibanaRequest } from 'src/core/server/http/router/request'; +import { defineGetComplianceDashboardRoute } from './compliance_dashboard'; + +import { CspAppService } from '../../lib/csp_app_services'; +import { CspAppContext } from '../../plugin'; + +describe('compliance dashboard permissions API', () => { + let logger: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + jest.clearAllMocks(); + }); + + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetComplianceDashboardRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetComplianceDashboardRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts index f554eb91a4a49..e414dab92606a 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { ElasticsearchClient, IRouter } from 'src/core/server'; +import type { ElasticsearchClient } from 'src/core/server'; import { transformError } from '@kbn/securitysolution-es-utils'; import type { AggregationsMultiBucketAggregateBase as Aggregation, @@ -19,6 +19,7 @@ import { CspAppContext } from '../../plugin'; import { getResourcesTypes } from './get_resources_types'; import { getClusters } from './get_clusters'; import { getStats } from './get_stats'; +import { CspRouter } from '../../types'; export interface ClusterBucket { ordered_top_hits: AggregationsTopHitsAggregate; @@ -75,7 +76,7 @@ const getLatestCyclesIds = async (esClient: ElasticsearchClient): Promise router.get( diff --git a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts index 4e534d565d7e3..c558caea1e9d9 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts @@ -6,7 +6,12 @@ */ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; -import { savedObjectsClientMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { + savedObjectsClientMock, + httpServiceMock, + loggingSystemMock, + httpServerMock, +} from 'src/core/server/mocks'; import { convertRulesConfigToYaml, createRulesConfig, @@ -24,6 +29,7 @@ import { createPackagePolicyServiceMock } from '../../../../fleet/server/mocks'; import { cspRuleAssetSavedObjectType, CspRuleSchema } from '../../../common/schemas/csp_rule'; import { ElasticsearchClient, + KibanaRequest, SavedObjectsClientContract, SavedObjectsFindResponse, } from 'kibana/server'; @@ -55,6 +61,54 @@ describe('Update rules configuration API', () => { expect(config.path).toEqual('/api/csp/update_rules_config'); }); + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineUpdateRulesConfigRoute(router, cspContext); + const [_, handler] = router.post.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineUpdateRulesConfigRoute(router, cspContext); + const [_, handler] = router.post.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + it('validate getCspRules input parameters', async () => { mockSoClient = savedObjectsClientMock.create(); mockSoClient.find.mockResolvedValueOnce({} as SavedObjectsFindResponse); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts index 50a4759c5ec52..a57d3902f266c 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts @@ -6,7 +6,6 @@ */ import type { ElasticsearchClient, - IRouter, SavedObjectsClientContract, SavedObjectsFindResponse, } from 'src/core/server'; @@ -24,6 +23,7 @@ import { CspRuleSchema, cspRuleAssetSavedObjectType } from '../../../common/sche import { UPDATE_RULES_CONFIG_ROUTE_PATH } from '../../../common/constants'; import { CIS_KUBERNETES_PACKAGE_NAME } from '../../../common/constants'; import { PackagePolicyServiceInterface } from '../../../../fleet/server'; +import { CspRouter } from '../../types'; export const getPackagePolicy = async ( soClient: SavedObjectsClientContract, @@ -99,13 +99,17 @@ export const updatePackagePolicy = ( return packagePolicyService.update(soClient, esClient, packagePolicy.id, updatedPackagePolicy); }; -export const defineUpdateRulesConfigRoute = (router: IRouter, cspContext: CspAppContext): void => +export const defineUpdateRulesConfigRoute = (router: CspRouter, cspContext: CspAppContext): void => router.post( { path: UPDATE_RULES_CONFIG_ROUTE_PATH, validate: { query: configurationUpdateInputSchema }, }, async (context, request, response) => { + if (!context.fleet.authz.fleet.all) { + return response.forbidden(); + } + try { const esClient = context.core.elasticsearch.client.asCurrentUser; const soClient = context.core.savedObjects.client; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts index cfd180a86169d..c41245db04685 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts @@ -27,6 +27,7 @@ export const getMockCspContext = (mockEsClient: ElasticsearchClientMock): Kibana client: { asCurrentUser: mockEsClient }, }, }, + fleet: { authz: { fleet: { all: true } } }, } as unknown as KibanaRequest; }; @@ -56,6 +57,54 @@ describe('findings API', () => { expect(config.path).toEqual('/api/csp/findings'); }); + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: false } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(1); + }); + describe('test input schema', () => { it('expect to find default values', async () => { const validatedQuery = findingsInputSchema.validate({}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts index ca95efae3d56a..cdbbfbe5ff69d 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { IRouter, Logger } from 'src/core/server'; +import type { Logger } from 'src/core/server'; import { SearchRequest, QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { QueryDslBoolQuery } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; @@ -16,6 +16,7 @@ import { getLatestCycleIds } from './get_latest_cycle_ids'; import { CSP_KUBEBEAT_INDEX_PATTERN, FINDINGS_ROUTE_PATH } from '../../../common/constants'; import { CspAppContext } from '../../plugin'; +import { CspRouter } from '../../types'; type FindingsQuerySchema = TypeOf; @@ -103,13 +104,17 @@ const buildOptionsRequest = (queryParams: FindingsQuerySchema): FindingsOptions ...getSearchFields(queryParams.fields), }); -export const defineFindingsIndexRoute = (router: IRouter, cspContext: CspAppContext): void => +export const defineFindingsIndexRoute = (router: CspRouter, cspContext: CspAppContext): void => router.get( { path: FINDINGS_ROUTE_PATH, validate: { query: findingsInputSchema }, }, async (context, request, response) => { + if (!context.fleet.authz.fleet.all) { + return response.forbidden(); + } + try { const esClient = context.core.elasticsearch.client.asCurrentUser; const options = buildOptionsRequest(request.query); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/index.ts b/x-pack/plugins/cloud_security_posture/server/routes/index.ts index aa04a610aa486..a0981e2a956cd 100755 --- a/x-pack/plugins/cloud_security_posture/server/routes/index.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/index.ts @@ -5,14 +5,14 @@ * 2.0. */ -import type { IRouter } from '../../../../../src/core/server'; import { defineGetComplianceDashboardRoute } from './compliance_dashboard/compliance_dashboard'; import { defineGetBenchmarksRoute } from './benchmarks/benchmarks'; import { defineFindingsIndexRoute as defineGetFindingsIndexRoute } from './findings/findings'; import { defineUpdateRulesConfigRoute } from './configuration/update_rules_configuration'; import { CspAppContext } from '../plugin'; +import { CspRouter } from '../types'; -export function defineRoutes(router: IRouter, cspContext: CspAppContext) { +export function defineRoutes(router: CspRouter, cspContext: CspAppContext) { defineGetComplianceDashboardRoute(router, cspContext); defineGetFindingsIndexRoute(router, cspContext); defineGetBenchmarksRoute(router, cspContext); diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts new file mode 100644 index 0000000000000..e1082cc59db3f --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsType } from '../../../../../src/core/server'; +import { + type CloudSecurityPostureRuleTemplateSchema, + cloudSecurityPostureRuleTemplateSavedObjectType, +} from '../../common/schemas/csp_rule_template'; + +const ruleTemplateAssetSavedObjectMappings: SavedObjectsType['mappings'] = + { + dynamic: false, + properties: {}, + }; + +export const cspRuleTemplateAssetType: SavedObjectsType = { + name: cloudSecurityPostureRuleTemplateSavedObjectType, + hidden: false, + management: { + importableAndExportable: true, + visibleInManagement: true, + }, + namespaceType: 'agnostic', + mappings: ruleTemplateAssetSavedObjectMappings, +}; diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts similarity index 90% rename from x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts rename to x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts index fcff7449fb3f5..4b323c127c0e6 100644 --- a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts @@ -6,15 +6,12 @@ */ import { i18n } from '@kbn/i18n'; -import type { - SavedObjectsType, - SavedObjectsValidationMap, -} from '../../../../../../src/core/server'; +import type { SavedObjectsType, SavedObjectsValidationMap } from '../../../../../src/core/server'; import { type CspRuleSchema, cspRuleSchema, cspRuleAssetSavedObjectType, -} from '../../../common/schemas/csp_rule'; +} from '../../common/schemas/csp_rule'; const validationMap: SavedObjectsValidationMap = { '1.0.0': cspRuleSchema, diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts similarity index 83% rename from x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts rename to x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts index 1cb08ddc1be1a..71e7697296acb 100644 --- a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts @@ -6,8 +6,8 @@ */ import type { ISavedObjectsRepository } from 'src/core/server'; -import { CIS_BENCHMARK_1_4_1_RULES } from './rules'; -import { cspRuleAssetSavedObjectType } from '../../../common/schemas/csp_rule'; +import { CIS_BENCHMARK_1_4_1_RULES } from './cis_1_4_1/rules'; +import { cspRuleAssetSavedObjectType } from '../../common/schemas/csp_rule'; export const initializeCspRules = async (client: ISavedObjectsRepository) => { const existingRules = await client.find({ type: cspRuleAssetSavedObjectType, perPage: 1 }); diff --git a/x-pack/plugins/cloud_security_posture/server/types.ts b/x-pack/plugins/cloud_security_posture/server/types.ts index 4e70027013df8..9fe602424321c 100644 --- a/x-pack/plugins/cloud_security_posture/server/types.ts +++ b/x-pack/plugins/cloud_security_posture/server/types.ts @@ -10,7 +10,14 @@ import type { PluginStart as DataPluginStart, } from '../../../../src/plugins/data/server'; -import type { FleetStartContract } from '../../fleet/server'; +import type { + RouteMethod, + KibanaResponseFactory, + RequestHandler, + IRouter, +} from '../../../../src/core/server'; + +import type { FleetStartContract, FleetRequestHandlerContext } from '../../fleet/server'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface CspServerPluginSetup {} @@ -29,3 +36,23 @@ export interface CspServerPluginStartDeps { data: DataPluginStart; fleet: FleetStartContract; } + +export type CspRequestHandlerContext = FleetRequestHandlerContext; + +/** + * Convenience type for request handlers in CSP that includes the CspRequestHandlerContext type + * @internal + */ +export type CspRequestHandler< + P = unknown, + Q = unknown, + B = unknown, + Method extends RouteMethod = any, + ResponseFactory extends KibanaResponseFactory = KibanaResponseFactory +> = RequestHandler; + +/** + * Convenience type for routers in Csp that includes the CspRequestHandlerContext type + * @internal + */ +export type CspRouter = IRouter; diff --git a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts index abfe089e82a38..aa8c2c0e3aa00 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts @@ -17,6 +17,7 @@ export async function getSearchStatus( asyncId: string ): Promise> { // TODO: Handle strategies other than the default one + // https://github.com/elastic/kibana/issues/127880 try { // @ts-expect-error start_time_in_millis: EpochMillis is string | number const apiResponse: TransportResult = await client.asyncSearch.status( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts index 25342f24cc872..9c06527162b81 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts @@ -9,7 +9,12 @@ import { Schema } from '../../../../shared/schema/types'; import { Fields } from './types'; -export const buildSearchUIConfig = (apiConnector: object, schema: Schema, fields: Fields) => { +export const buildSearchUIConfig = ( + apiConnector: object, + schema: Schema, + fields: Fields, + initialState = { sortDirection: 'desc', sortField: 'id' } +) => { const facets = fields.filterFields.reduce( (facetsConfig, fieldName) => ({ ...facetsConfig, @@ -22,10 +27,7 @@ export const buildSearchUIConfig = (apiConnector: object, schema: Schema, fields alwaysSearchOnInitialLoad: true, apiConnector, trackUrlState: false, - initialState: { - sortDirection: 'desc', - sortField: 'id', - }, + initialState, searchQuery: { disjunctiveFacets: fields.filterFields, facets, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx index ed2a1ed54f06d..52e0acbc81520 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx @@ -31,25 +31,39 @@ import { SearchExperienceContent } from './search_experience_content'; import { Fields, SortOption } from './types'; import { SearchBoxView, SortingView, MultiCheckboxFacetsView } from './views'; -const RECENTLY_UPLOADED = i18n.translate( - 'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded', +const DOCUMENT_ID = i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.documentId', { - defaultMessage: 'Recently Uploaded', + defaultMessage: 'Document ID', } ); + +const RELEVANCE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.relevance', + { defaultMessage: 'Relevance' } +); + const DEFAULT_SORT_OPTIONS: SortOption[] = [ { - name: DESCENDING(RECENTLY_UPLOADED), + name: DESCENDING(DOCUMENT_ID), value: 'id', direction: 'desc', }, { - name: ASCENDING(RECENTLY_UPLOADED), + name: ASCENDING(DOCUMENT_ID), value: 'id', direction: 'asc', }, ]; +const RELEVANCE_SORT_OPTIONS: SortOption[] = [ + { + name: RELEVANCE, + value: '_score', + direction: 'desc', + }, +]; + export const SearchExperience: React.FC = () => { const { engine } = useValues(EngineLogic); const { http } = useValues(HttpLogic); @@ -66,8 +80,10 @@ export const SearchExperience: React.FC = () => { sortFields: [], } ); + const sortOptions = + engine.type === 'elasticsearch' ? RELEVANCE_SORT_OPTIONS : DEFAULT_SORT_OPTIONS; - const sortingOptions = buildSortOptions(fields, DEFAULT_SORT_OPTIONS); + const sortingOptions = buildSortOptions(fields, sortOptions); const connector = new AppSearchAPIConnector({ cacheResponses: false, @@ -78,7 +94,17 @@ export const SearchExperience: React.FC = () => { }, }); - const searchProviderConfig = buildSearchUIConfig(connector, engine.schema || {}, fields); + const initialState = { + sortField: engine.type === 'elasticsearch' ? '_score' : 'id', + sortDirection: 'desc', + }; + + const searchProviderConfig = buildSearchUIConfig( + connector, + engine.schema || {}, + fields, + initialState + ); return (
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts index 6faa749f95864..acdeed4854ecd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts @@ -12,6 +12,7 @@ export enum EngineTypes { default = 'default', indexed = 'indexed', meta = 'meta', + elasticsearch = 'elasticsearch', } export interface Engine { name: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx similarity index 88% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx index 6a93291a28cb3..4917877c0ec30 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import '../../../../../__mocks__/shallow_useeffect.mock'; -import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; -import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; +import '../../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -18,8 +18,8 @@ import { EuiSteps } from '@elastic/eui'; import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, -} from '../../../../components/layout'; -import { staticSourceData } from '../../source_data'; +} from '../../../../../components/layout'; +import { staticSourceData } from '../../../source_data'; import { ExternalConnectorConfig } from './external_connector_config'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx similarity index 81% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx index 637be68929ac0..002cafa2e3229 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx @@ -21,17 +21,20 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { AppLogic } from '../../../../app_logic'; +import { AppLogic } from '../../../../../app_logic'; import { PersonalDashboardLayout, WorkplaceSearchPageTemplate, -} from '../../../../components/layout'; -import { NAV, REMOVE_BUTTON } from '../../../../constants'; -import { SourceDataItem } from '../../../../types'; +} from '../../../../../components/layout'; +import { NAV, REMOVE_BUTTON } from '../../../../../constants'; +import { SourceDataItem } from '../../../../../types'; -import { AddSourceHeader } from './add_source_header'; -import { ConfigDocsLinks } from './config_docs_links'; -import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './constants'; +import { staticExternalSourceData } from '../../../source_data'; + +import { AddSourceHeader } from './../add_source_header'; +import { ConfigDocsLinks } from './../config_docs_links'; +import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './../constants'; +import { ExternalConnectorDocumentation } from './external_connector_documentation'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; import { ExternalConnectorLogic } from './external_connector_logic'; @@ -69,10 +72,14 @@ export const ExternalConnectorConfig: React.FC = ({ const { name, categories } = sourceConfigData; const { - configuration: { documentationUrl, applicationLinkTitle, applicationPortalUrl }, + configuration: { applicationLinkTitle, applicationPortalUrl }, } = sourceData; const { isOrganization } = useValues(AppLogic); + const { + configuration: { documentationUrl }, + } = staticExternalSourceData; + const saveButton = ( {OAUTH_SAVE_CONFIG_BUTTON} @@ -135,6 +142,8 @@ export const ExternalConnectorConfig: React.FC = ({ {header} + +
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx new file mode 100644 index 0000000000000..13b8967637ee1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx @@ -0,0 +1,24 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { EuiText } from '@elastic/eui'; + +import { ExternalConnectorDocumentation } from './external_connector_documentation'; + +describe('ExternalDocumentation', () => { + it('renders', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiText)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx new file mode 100644 index 0000000000000..437bf6f683198 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiText, EuiLink } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +interface ExternalConnectorDocumentationProps { + name: string; + documentationUrl: string; +} + +export const ExternalConnectorDocumentation: React.FC = ({ + name, + documentationUrl, +}) => { + return ( + +

+ +

+

+ + + + ), + }} + /> +

+

+ + + +

+

+ + + +

+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx similarity index 95% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx index 931a2f3517fbb..45a7dd122eabf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import '../../../../../__mocks__/shallow_useeffect.mock'; -import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; +import '../../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; import React from 'react'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts similarity index 97% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts index 38bf74052541c..0e9ad386a353d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts @@ -10,18 +10,19 @@ import { mockFlashMessageHelpers, mockHttpValues, mockKibanaValues, -} from '../../../../../__mocks__/kea_logic'; -import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; +} from '../../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test-jest-helpers'; -import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; +import { itShowsServerErrorAsFlashMessage } from '../../../../../../test_helpers'; -jest.mock('../../../../app_logic', () => ({ +jest.mock('../../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); -import { AddSourceLogic, SourceConfigData } from './add_source_logic'; +import { AddSourceLogic, SourceConfigData } from '../add_source_logic'; + import { ExternalConnectorLogic, ExternalConnectorValues } from './external_connector_logic'; describe('ExternalConnectorLogic', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts similarity index 94% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts index 1f7edf0d8e2a9..3bf96a31dd8c5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts @@ -13,14 +13,14 @@ import { flashAPIErrors, flashSuccessToast, clearFlashMessages, -} from '../../../../../shared/flash_messages'; -import { HttpLogic } from '../../../../../shared/http'; -import { KibanaLogic } from '../../../../../shared/kibana'; -import { AppLogic } from '../../../../app_logic'; +} from '../../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../../shared/http'; +import { KibanaLogic } from '../../../../../../shared/kibana'; +import { AppLogic } from '../../../../../app_logic'; -import { getAddPath, getSourcesPath } from '../../../../routes'; +import { getAddPath, getSourcesPath } from '../../../../../routes'; -import { AddSourceLogic, SourceConfigData } from './add_source_logic'; +import { AddSourceLogic, SourceConfigData } from '../add_source_logic'; export interface ExternalConnectorActions { fetchExternalSource: () => true; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts new file mode 100644 index 0000000000000..7f2871a9f5c75 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ExternalConnectorConfig } from './external_connector_config'; +export { ExternalConnectorFormFields } from './external_connector_form_fields'; +export { ExternalConnectorLogic } from './external_connector_logic'; +export { ExternalConnectorDocumentation } from './external_connector_documentation'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index 21246defbb863..6b335b1f7ffe4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -29,6 +29,7 @@ import { FeatureIds } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; import { SourcesLogic } from '../../sources_logic'; +import { ExternalConnectorLogic } from './add_external_connector/external_connector_logic'; import { AddSourceLogic, AddSourceSteps, @@ -38,7 +39,6 @@ import { AddSourceValues, AddSourceProps, } from './add_source_logic'; -import { ExternalConnectorLogic } from './external_connector_logic'; describe('AddSourceLogic', () => { const { mount } = new LogicMounter(AddSourceLogic); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index 8693cffc17e21..c621e0ee16bd5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -25,7 +25,10 @@ import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; import { SourcesLogic } from '../../sources_logic'; -import { ExternalConnectorLogic, isValidExternalUrl } from './external_connector_logic'; +import { + ExternalConnectorLogic, + isValidExternalUrl, +} from './add_external_connector/external_connector_logic'; export interface AddSourceProps { sourceData: SourceDataItem; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx index 9a5673451cd1a..8d8311d2a0a6f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx @@ -30,7 +30,7 @@ interface CardProps { description: string; buttonText: string; onClick: () => void; - betaBadgeLabel?: string; + badgeLabel?: string; } export const ConfigurationChoice: React.FC = ({ @@ -75,14 +75,14 @@ export const ConfigurationChoice: React.FC = ({ description, buttonText, onClick, - betaBadgeLabel, + badgeLabel, }: CardProps) => ( {buttonText} @@ -96,13 +96,14 @@ export const ConfigurationChoice: React.FC = ({ title: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.title', { - defaultMessage: 'Default connector', + defaultMessage: 'Connector', } ), description: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.description', { - defaultMessage: 'Use our out-of-the-box connector to get started quickly.', + defaultMessage: + 'Use this connector to get started quickly without deploying additional infrastructure.', } ), buttonText: i18n.translate( @@ -111,6 +112,12 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Connect', } ), + badgeLabel: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.recommendedLabel', + { + defaultMessage: 'Recommended', + } + ), onClick: goToInternal, }; @@ -118,13 +125,14 @@ export const ConfigurationChoice: React.FC = ({ title: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.title', { - defaultMessage: 'Custom connector', + defaultMessage: 'Connector Package', } ), description: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.description', { - defaultMessage: 'Set up a custom connector for more configurability and control.', + defaultMessage: + 'Deploy this connector package on self-managed infrastructure for advanced use cases.', } ), buttonText: i18n.translate( @@ -134,7 +142,7 @@ export const ConfigurationChoice: React.FC = ({ } ), onClick: goToExternal, - betaBadgeLabel: i18n.translate( + badgeLabel: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.betaLabel', { defaultMessage: 'Beta', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx index 5c234be583b9d..3e35c608fcee8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx @@ -18,8 +18,8 @@ import { EuiSteps, EuiButton, EuiButtonEmpty } from '@elastic/eui'; import { ApiKey } from '../../../../components/shared/api_key'; import { staticSourceData } from '../../source_data'; +import { ExternalConnectorFormFields } from './add_external_connector'; import { ConfigDocsLinks } from './config_docs_links'; -import { ExternalConnectorFormFields } from './external_connector_form_fields'; import { SaveConfig } from './save_config'; describe('SaveConfig', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx index d56efcdab95d6..eb887a9f8cc42 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx @@ -35,10 +35,11 @@ import { } from '../../../../constants'; import { Configuration } from '../../../../types'; +import { ExternalConnectorFormFields } from './add_external_connector'; +import { ExternalConnectorDocumentation } from './add_external_connector'; import { AddSourceLogic } from './add_source_logic'; import { ConfigDocsLinks } from './config_docs_links'; import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON, OAUTH_STEP_2 } from './constants'; -import { ExternalConnectorFormFields } from './external_connector_form_fields'; interface SaveConfigProps { header: React.ReactNode; @@ -224,6 +225,12 @@ export const SaveConfig: React.FC = ({ <> {header} + {serviceType === 'external' && ( + <> + + + + )}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index 361eccbe8da38..5b1e4d97ef4cd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -12,6 +12,35 @@ import { docLinks } from '../../../shared/doc_links'; import { SOURCE_NAMES, SOURCE_OBJ_TYPES, GITHUB_LINK_TITLE } from '../../constants'; import { FeatureIds, SourceDataItem } from '../../types'; +export const staticExternalSourceData: SourceDataItem = { + name: SOURCE_NAMES.SHAREPOINT, + iconName: SOURCE_NAMES.SHAREPOINT, + serviceType: 'external', + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchExternalSharePointOnline, + applicationPortalUrl: 'https://portal.azure.com/', + }, + objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], + features: { + basicOrgContext: [ + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + FeatureIds.GlobalAccessPermissions, + ], + basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], + platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + platinumPrivateContext: [FeatureIds.Private, FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + }, + accountContextOnly: false, + internalConnectorAvailable: true, + externalConnectorAvailable: false, + customConnectorAvailable: false, + isBeta: true, +}; + export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.BOX, @@ -502,39 +531,7 @@ export const staticSourceData: SourceDataItem[] = [ internalConnectorAvailable: true, externalConnectorAvailable: true, }, - // TODO: temporary hack until backend sends us stuff - { - name: SOURCE_NAMES.SHAREPOINT, - iconName: SOURCE_NAMES.SHAREPOINT, - serviceType: 'external', - configuration: { - isPublicKey: false, - hasOauthRedirect: true, - needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchExternalSharePointOnline, - applicationPortalUrl: 'https://portal.azure.com/', - }, - objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], - features: { - basicOrgContext: [ - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - FeatureIds.GlobalAccessPermissions, - ], - basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], - platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], - platinumPrivateContext: [ - FeatureIds.Private, - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - ], - }, - accountContextOnly: false, - internalConnectorAvailable: true, - externalConnectorAvailable: false, - customConnectorAvailable: false, - isBeta: true, - }, + staticExternalSourceData, { name: SOURCE_NAMES.SHAREPOINT_SERVER, iconName: SOURCE_NAMES.SHAREPOINT_SERVER, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index e735119f687cc..19af955f8780c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -30,8 +30,8 @@ import { hasMultipleConnectorOptions } from '../../utils'; import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source'; import { AddCustomSource } from './components/add_source/add_custom_source'; +import { ExternalConnectorConfig } from './components/add_source/add_external_connector'; import { ConfigurationChoice } from './components/add_source/configuration_choice'; -import { ExternalConnectorConfig } from './components/add_source/external_connector_config'; import { OrganizationSources } from './organization_sources'; import { PrivateSources } from './private_sources'; import { staticCustomSourceData, staticSourceData as sources } from './source_data'; diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts index 6f48b15158f8d..0b4f30a137192 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts @@ -252,6 +252,7 @@ export const item: GetInfoResponse['item'] = { lens: [], map: [], security_rule: [], + csp_rule_template: [], tag: [], }, elasticsearch: { diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts index 6b766c2d126df..5c08120084cb9 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts @@ -105,6 +105,7 @@ export const item: GetInfoResponse['item'] = { lens: [], ml_module: [], security_rule: [], + csp_rule_template: [], tag: [], }, elasticsearch: { diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index b355a62fbf241..e9bb796626f58 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -3585,7 +3585,8 @@ "map", "lens", "ml-module", - "security-rule" + "security-rule", + "csp-rule-template" ] }, "elasticsearch_asset_type": { diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 9a352f94e8252..f7941f863c120 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -2238,6 +2238,7 @@ components: - lens - ml-module - security-rule + - csp_rule_template elasticsearch_asset_type: title: Elasticsearch asset type type: string diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml index 4ec82e7507166..1a7d29311e4fe 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml @@ -9,3 +9,4 @@ enum: - lens - ml-module - security-rule + - csp_rule_template diff --git a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts index 0cf8c3e88f568..ee47c3faa305a 100644 --- a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts @@ -25,6 +25,7 @@ describe('Fleet - packageToPackagePolicy', () => { path: '', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index dcff9f503bfe0..93be8684698ca 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -72,6 +72,7 @@ export enum KibanaAssetType { map = 'map', lens = 'lens', securityRule = 'security_rule', + cloudSecurityPostureRuleTemplate = 'csp_rule_template', mlModule = 'ml_module', tag = 'tag', } @@ -88,6 +89,7 @@ export enum KibanaSavedObjectType { lens = 'lens', mlModule = 'ml-module', securityRule = 'security-rule', + cloudSecurityPostureRuleTemplate = 'csp-rule-template', tag = 'tag', } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx index 1ba7f09d0333d..9fdcc0f73297f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx @@ -309,6 +309,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = isInvalid={Boolean(touchedFields.data_output_id && validation.data_output_id)} > = isInvalid={Boolean(touchedFields.monitoring_output_id && validation.monitoring_output_id)} > { = { tag: i18n.translate('xpack.fleet.epm.assetTitles.tag', { defaultMessage: 'Tag', }), + csp_rule_template: i18n.translate( + 'xpack.fleet.epm.assetTitles.cloudSecurityPostureRuleTemplate', + { + defaultMessage: 'Cloud Security Posture rule template', + } + ), }; export const ServiceTitleMap: Record = { @@ -89,6 +95,7 @@ export const AssetIcons: Record = { map: 'emsApp', lens: 'lensApp', security_rule: 'securityApp', + csp_rule_template: 'securityApp', // TODO ICON ml_module: 'mlApp', tag: 'tagApp', }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index d002a743e77bc..dbd1c71da3d1b 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -144,9 +144,13 @@ export function Detail() { // Refresh package info when status change const [oldPackageInstallStatus, setOldPackageStatus] = useState(packageInstallStatus); + useEffect(() => { + if (packageInstallStatus === 'not_installed') { + setOldPackageStatus(packageInstallStatus); + } if (oldPackageInstallStatus === 'not_installed' && packageInstallStatus === 'installed') { - setOldPackageStatus(oldPackageInstallStatus); + setOldPackageStatus(packageInstallStatus); refreshPackageInfo(); } }, [packageInstallStatus, oldPackageInstallStatus, refreshPackageInfo]); diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts index 4016d4ea690c4..dbf1db0f68f28 100644 --- a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts @@ -142,13 +142,15 @@ export const updateAgentPolicyHandler: RequestHandler< const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asInternalUser; const user = await appContextService.getSecurity()?.authc.getCurrentUser(request); + const { force, ...data } = request.body; try { const agentPolicy = await agentPolicyService.update( soClient, esClient, request.params.agentPolicyId, - request.body, + data, { + force, user: user || undefined, } ); diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 170942d59061f..c34104e491da8 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { uniq, omit } from 'lodash'; +import { uniq, omit, isEqual } from 'lodash'; import uuid from 'uuid/v4'; import uuidv5 from 'uuid/v5'; import { safeDump } from 'js-yaml'; @@ -68,6 +68,8 @@ import { validateOutputForPolicy } from './agent_policies'; const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE; +const KEY_EDITABLE_FOR_MANAGED_POLICIES = ['namespace']; + class AgentPolicyService { private triggerAgentPolicyUpdatedEvent = async ( soClient: SavedObjectsClientContract, @@ -344,7 +346,7 @@ class AgentPolicyService { esClient: ElasticsearchClient, id: string, agentPolicy: Partial, - options?: { user?: AuthenticatedUser } + options?: { user?: AuthenticatedUser; force?: boolean } ): Promise { if (agentPolicy.name) { await this.requireUniqueName(soClient, { @@ -352,6 +354,23 @@ class AgentPolicyService { name: agentPolicy.name, }); } + + const existingAgentPolicy = await this.get(soClient, id, true); + + if (!existingAgentPolicy) { + throw new Error('Agent policy not found'); + } + + if (existingAgentPolicy.is_managed && !options?.force) { + Object.entries(agentPolicy) + .filter(([key]) => !KEY_EDITABLE_FOR_MANAGED_POLICIES.includes(key)) + .forEach(([key, val]) => { + if (!isEqual(existingAgentPolicy[key as keyof AgentPolicy], val)) { + throw new HostedAgentPolicyRestrictionRelatedError(`Cannot update ${key}`); + } + }); + } + return this._update(soClient, esClient, id, agentPolicy, options?.user); } diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index 86edf1c5e4064..77ce3779f2319 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -157,6 +157,27 @@ describe('EPM template', () => { expect(mappings).toEqual(longWithIndexFalseMapping); }); + it('tests processing keyword field with doc_values false', () => { + const keywordWithIndexFalseYml = ` +- name: keywordIndexFalse + type: keyword + doc_values: false +`; + const keywordWithIndexFalseMapping = { + properties: { + keywordIndexFalse: { + ignore_above: 1024, + type: 'keyword', + doc_values: false, + }, + }, + }; + const fields: Field[] = safeLoad(keywordWithIndexFalseYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(keywordWithIndexFalseMapping); + }); + it('tests processing text field with multi fields', () => { const textWithMultiFieldsLiteralYml = ` - name: textWithMultiFields @@ -378,6 +399,34 @@ describe('EPM template', () => { expect(mappings).toEqual(keywordWithMultiFieldsMapping); }); + it('tests processing wildcard field with multi fields with match_only_text type', () => { + const wildcardWithMultiFieldsLiteralYml = ` +- name: wildcardWithMultiFields + type: wildcard + multi_fields: + - name: text + type: match_only_text +`; + + const wildcardWithMultiFieldsMapping = { + properties: { + wildcardWithMultiFields: { + ignore_above: 1024, + type: 'wildcard', + fields: { + text: { + type: 'match_only_text', + }, + }, + }, + }, + }; + const fields: Field[] = safeLoad(wildcardWithMultiFieldsLiteralYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(wildcardWithMultiFieldsMapping); + }); + it('tests processing object field with no other attributes', () => { const objectFieldLiteralYml = ` - name: objectField diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 21c7351b31384..909b593649fcd 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -244,9 +244,8 @@ function generateMultiFields(fields: Fields): MultiFields { multiFields[f.name] = { ...generateKeywordMapping(f), type: f.type }; break; case 'long': - multiFields[f.name] = { type: f.type }; - break; case 'double': + case 'match_only_text': multiFields[f.name] = { type: f.type }; break; } @@ -302,7 +301,7 @@ function getDefaultProperties(field: Field): Properties { if (field.index !== undefined) { properties.index = field.index; } - if (field.doc_values) { + if (field.doc_values !== undefined) { properties.doc_values = field.doc_values; } if (field.copy_to) { diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index e76e44476df03..491e4e27825c4 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -52,6 +52,8 @@ const KibanaSavedObjectTypeMapping: Record { status: 'not_installed', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], @@ -170,6 +171,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { status: 'not_installed', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], @@ -262,6 +264,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { status: 'not_installed', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], @@ -386,6 +389,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { status: 'not_installed', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 27919d7bf1011..862b589896793 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -689,7 +689,10 @@ describe('policy preconfiguration', () => { name: 'Renamed Test policy', description: 'Renamed Test policy description', unenroll_timeout: 999, - }) + }), + { + force: true, + } ); expect(policies.length).toEqual(1); expect(policies[0].id).toBe('test-id'); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index 6f8c8bbc6a20d..c11925fa8f2f3 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -159,7 +159,10 @@ export async function ensurePreconfiguredPackagesAndPolicies( soClient, esClient, String(preconfiguredAgentPolicy.id), - fields + fields, + { + force: true, + } ); return { created, policy: updatedPolicy }; } @@ -254,7 +257,15 @@ export async function ensurePreconfiguredPackagesAndPolicies( // Add the is_managed flag after configuring package policies to avoid errors if (shouldAddIsManagedFlag) { - await agentPolicyService.update(soClient, esClient, policy!.id, { is_managed: true }); + await agentPolicyService.update( + soClient, + esClient, + policy!.id, + { is_managed: true }, + { + force: true, + } + ); } } } diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts index 64d142f150bfd..042129e1e0914 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts @@ -32,7 +32,9 @@ export const CreateAgentPolicyRequestSchema = { export const UpdateAgentPolicyRequestSchema = { ...GetOneAgentPolicyRequestSchema, - body: NewAgentPolicySchema, + body: NewAgentPolicySchema.extends({ + force: schema.maybe(schema.boolean()), + }), }; export const CopyAgentPolicyRequestSchema = { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap index 25930c07fcd8b..802d684a8a261 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap @@ -130,7 +130,6 @@ exports[`extend index management ilm summary extension should return extension w "step": "ERROR", "step_info": Object { "reason": "setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined", - "stack_trace": "fakestacktrace", "type": "illegal_argument_exception", }, "step_time_millis": 1544187776208, @@ -332,81 +331,6 @@ exports[`extend index management ilm summary extension should return extension w illegal_argument_exception : setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined - -
- - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="stackPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" - > -
-
- - - -
-
-
diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx index eaebd6381d984..544aad4c52088 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx @@ -113,7 +113,6 @@ const indexWithLifecycleError = { step_info: { type: 'illegal_argument_exception', reason: 'setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined', - stack_trace: 'fakestacktrace', }, phase_execution: { policy: 'testy', diff --git a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts index 085179f14913d..ad1b1b2b28880 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts @@ -229,7 +229,6 @@ export interface IndexLifecyclePolicy { step?: string; step_info?: { reason?: string; - stack_trace?: string; type?: string; message?: string; }; diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx index 4a34a4eb11ea4..fa148a5ba960b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx @@ -10,7 +10,6 @@ import moment from 'moment-timezone'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { - EuiButtonEmpty, EuiCallOut, EuiCodeBlock, EuiFlexGroup, @@ -108,31 +107,6 @@ export class IndexLifecycleSummary extends Component { closePhaseExecutionPopover = () => { this.setState({ showPhaseExecutionPopover: false }); }; - renderStackPopoverButton(ilm: IndexLifecyclePolicy) { - if (!ilm.step_info!.stack_trace) { - return null; - } - const button = ( - - - - ); - return ( - -
-
{ilm.step_info!.stack_trace}
-
-
- ); - } renderPhaseExecutionPopoverButton(ilm: IndexLifecyclePolicy) { const button = ( @@ -257,12 +231,10 @@ export class IndexLifecycleSummary extends Component { iconType="cross" > {ilm.step_info.type}: {ilm.step_info.reason} - - {this.renderStackPopoverButton(ilm)} ) : null} - {ilm.step_info && ilm.step_info!.message && !ilm.step_info!.stack_trace ? ( + {ilm.step_info && ilm.step_info!.message ? ( <> | any[]; +type HttpMethod = 'GET' | 'PUT' | 'DELETE' | 'POST'; export interface ResponseError { statusCode: number; @@ -17,139 +18,105 @@ export interface ResponseError { } // Register helpers to mock HTTP Requests -const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { - const setLoadTemplatesResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/index_templates`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setLoadIndicesResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/indices`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setReloadIndicesResponse = (response: HttpResponse = []) => { - server.respondWith('POST', `${API_BASE_PATH}/indices/reload`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setLoadDataStreamsResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/data_streams`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setLoadDataStreamResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/data_streams/:id`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setDeleteDataStreamResponse = (response: HttpResponse = []) => { - server.respondWith('POST', `${API_BASE_PATH}/delete_data_streams`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setDeleteTemplateResponse = (response: HttpResponse = []) => { - server.respondWith('POST', `${API_BASE_PATH}/delete_index_templates`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setLoadTemplateResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; - - server.respondWith('GET', `${API_BASE_PATH}/index_templates/:id`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setCreateTemplateResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.body.status || 400 : 200; - const body = error ? JSON.stringify(error.body) : JSON.stringify(response); - - server.respondWith('POST', `${API_BASE_PATH}/index_templates`, [ - status, - { 'Content-Type': 'application/json' }, - body, - ]); - }; - - const setUpdateTemplateResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? JSON.stringify(error.body) : JSON.stringify(response); - - server.respondWith('PUT', `${API_BASE_PATH}/index_templates/:name`, [ - status, - { 'Content-Type': 'application/json' }, - body, - ]); - }; - - const setUpdateIndexSettingsResponse = (response?: HttpResponse, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ?? response; - - server.respondWith('PUT', `${API_BASE_PATH}/settings/:name`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setSimulateTemplateResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? JSON.stringify(error.body) : JSON.stringify(response); - - server.respondWith('POST', `${API_BASE_PATH}/index_templates/simulate`, [ - status, - { 'Content-Type': 'application/json' }, - body, - ]); - }; - - const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; - - server.respondWith('GET', `${API_BASE_PATH}/component_templates`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setLoadNodesPluginsResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; - - server.respondWith('GET', `${API_BASE_PATH}/nodes/plugins`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; +const registerHttpRequestMockHelpers = ( + httpSetup: ReturnType +) => { + const mockResponses = new Map>>( + ['GET', 'PUT', 'DELETE', 'POST'].map( + (method) => [method, new Map()] as [HttpMethod, Map>] + ) + ); + + const mockMethodImplementation = (method: HttpMethod, path: string) => { + return mockResponses.get(method)?.get(path) ?? Promise.resolve({}); + }; + + httpSetup.get.mockImplementation((path) => + mockMethodImplementation('GET', path as unknown as string) + ); + httpSetup.delete.mockImplementation((path) => + mockMethodImplementation('DELETE', path as unknown as string) + ); + httpSetup.post.mockImplementation((path) => + mockMethodImplementation('POST', path as unknown as string) + ); + httpSetup.put.mockImplementation((path) => + mockMethodImplementation('PUT', path as unknown as string) + ); + + const mockResponse = (method: HttpMethod, path: string, response?: unknown, error?: unknown) => { + const defuse = (promise: Promise) => { + promise.catch(() => {}); + return promise; + }; + + return mockResponses + .get(method)! + .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response)); + }; + + const setLoadTemplatesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/index_templates`, response, error); + + const setLoadIndicesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/indices`, response, error); + + const setReloadIndicesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/indices/reload`, response, error); + + const setLoadDataStreamsResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/data_streams`, response, error); + + const setLoadDataStreamResponse = ( + dataStreamId: string, + response?: HttpResponse, + error?: ResponseError + ) => + mockResponse( + 'GET', + `${API_BASE_PATH}/data_streams/${encodeURIComponent(dataStreamId)}`, + response, + error + ); + + const setDeleteDataStreamResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/delete_data_streams`, response, error); + + const setDeleteTemplateResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/delete_index_templates`, response, error); + + const setLoadTemplateResponse = ( + templateId: string, + response?: HttpResponse, + error?: ResponseError + ) => mockResponse('GET', `${API_BASE_PATH}/index_templates/${templateId}`, response, error); + + const setCreateTemplateResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/index_templates`, response, error); + + const setUpdateTemplateResponse = ( + templateId: string, + response?: HttpResponse, + error?: ResponseError + ) => mockResponse('PUT', `${API_BASE_PATH}/index_templates/${templateId}`, response, error); + + const setUpdateIndexSettingsResponse = ( + indexName: string, + response?: HttpResponse, + error?: ResponseError + ) => mockResponse('PUT', `${API_BASE_PATH}/settings/${indexName}`, response, error); + + const setSimulateTemplateResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/index_templates/simulate`, response, error); + + const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/component_templates`, response, error); + + const setLoadNodesPluginsResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/nodes/plugins`, response, error); + + const setLoadTelemetryResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', '/api/ui_counters/_report', response, error); return { setLoadTemplatesResponse, @@ -166,22 +133,16 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { setSimulateTemplateResponse, setLoadComponentTemplatesResponse, setLoadNodesPluginsResponse, + setLoadTelemetryResponse, }; }; export const init = () => { - const server = sinon.fakeServer.create(); - server.respondImmediately = true; - - // Define default response for unhandled requests. - // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry, - // and we can mock them all with a 200 instead of mocking each one individually. - server.respondWith([200, {}, 'DefaultSinonMockServerResponse']); - - const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server); + const httpSetup = httpServiceMock.createSetupContract(); + const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup); return { - server, + httpSetup, httpRequestsMockHelpers, }; }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx index 1682431900a84..c5b077ef00333 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx @@ -6,11 +6,10 @@ */ import React from 'react'; -import axios from 'axios'; -import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { merge } from 'lodash'; import SemVer from 'semver/classes/semver'; +import { HttpSetup } from 'src/core/public'; import { notificationServiceMock, docLinksServiceMock, @@ -36,7 +35,6 @@ import { import { componentTemplatesMockDependencies } from '../../../public/application/components/component_templates/__jest__'; import { init as initHttpRequests } from './http_requests'; -const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); const { GlobalFlyoutProvider } = GlobalFlyout; export const services = { @@ -64,30 +62,24 @@ const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ }); export const setupEnvironment = () => { - // Mock initialization of services - // @ts-ignore - httpService.setup(mockHttpClient); breadcrumbService.setup(() => undefined); documentationService.setup(docLinksServiceMock.createStartContract()); notificationService.setup(notificationServiceMock.createSetupContract()); - const { server, httpRequestsMockHelpers } = initHttpRequests(); - - return { - server, - httpRequestsMockHelpers, - }; + return initHttpRequests(); }; export const WithAppDependencies = - (Comp: any, overridingDependencies: any = {}) => + (Comp: any, httpSetup: HttpSetup, overridingDependencies: any = {}) => (props: any) => { + httpService.setup(httpSetup); const mergedDependencies = merge({}, appDependencies, overridingDependencies); + return ( - + diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts index e3295a8f4fb18..9eeab1d3ca78b 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts @@ -15,6 +15,7 @@ import { AsyncTestBedConfig, findTestSubject, } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { DataStream } from '../../../common'; import { IndexManagementHome } from '../../../public/application/sections/home'; import { indexManagementStore } from '../../../public/application/store'; @@ -46,7 +47,10 @@ export interface DataStreamsTabTestBed extends TestBed { findDetailPanelIndexTemplateLink: () => ReactWrapper; } -export const setup = async (overridingDependencies: any = {}): Promise => { +export const setup = async ( + httpSetup: HttpSetup, + overridingDependencies: any = {} +): Promise => { const testBedConfig: AsyncTestBedConfig = { store: () => indexManagementStore(services as any), memoryRouter: { @@ -57,7 +61,7 @@ export const setup = async (overridingDependencies: any = {}): Promise { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: DataStreamsTabTestBed; - afterAll(() => { - server.restore(); - }); - describe('when there are no data streams', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([]); @@ -53,7 +49,7 @@ describe('Data Streams tab', () => { }); test('displays an empty prompt', async () => { - testBed = await setup({ + testBed = await setup(httpSetup, { url: urlServiceMock, }); @@ -69,7 +65,7 @@ describe('Data Streams tab', () => { }); test('when Ingest Manager is disabled, goes to index templates tab when "Get started" link is clicked', async () => { - testBed = await setup({ + testBed = await setup(httpSetup, { plugins: {}, url: urlServiceMock, }); @@ -89,7 +85,7 @@ describe('Data Streams tab', () => { }); test('when Fleet is enabled, links to Fleet', async () => { - testBed = await setup({ + testBed = await setup(httpSetup, { plugins: { isFleetEnabled: true }, url: urlServiceMock, }); @@ -112,7 +108,7 @@ describe('Data Streams tab', () => { }); httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]); - testBed = await setup({ + testBed = await setup(httpSetup, { plugins: {}, url: urlServiceMock, }); @@ -156,13 +152,13 @@ describe('Data Streams tab', () => { }), ]); - setLoadDataStreamResponse(dataStreamForDetailPanel); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); const indexTemplate = fixtures.getTemplate({ name: 'indexTemplate' }); setLoadTemplatesResponse({ templates: [indexTemplate], legacyTemplates: [] }); - setLoadTemplateResponse(indexTemplate); + setLoadTemplateResponse(indexTemplate.name, indexTemplate); - testBed = await setup({ history: createMemoryHistory() }); + testBed = await setup(httpSetup, { history: createMemoryHistory() }); await act(async () => { testBed.actions.goToDataStreamsList(); }); @@ -181,7 +177,6 @@ describe('Data Streams tab', () => { test('has a button to reload the data streams', async () => { const { exists, actions } = testBed; - const totalRequests = server.requests.length; expect(exists('reloadButton')).toBe(true); @@ -189,13 +184,14 @@ describe('Data Streams tab', () => { actions.clickReloadButton(); }); - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe(`${API_BASE_PATH}/data_streams`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/data_streams`, + expect.anything() + ); }); test('has a switch that will reload the data streams with additional stats when clicked', async () => { const { exists, actions, table, component } = testBed; - const totalRequests = server.requests.length; expect(exists('includeStatsSwitch')).toBe(true); @@ -205,9 +201,10 @@ describe('Data Streams tab', () => { }); component.update(); - // A request is sent, but sinon isn't capturing the query parameters for some reason. - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe(`${API_BASE_PATH}/data_streams`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/data_streams`, + expect.anything() + ); // The table renders with the stats columns though. const { tableCellsValues } = table.getMetaData('dataStreamTable'); @@ -279,19 +276,17 @@ describe('Data Streams tab', () => { await clickConfirmDelete(); - const { method, url, requestBody } = server.requests[server.requests.length - 1]; - - expect(method).toBe('POST'); - expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`); - expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({ - dataStreams: ['dataStream1'], - }); + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/delete_data_streams`, + expect.objectContaining({ body: JSON.stringify({ dataStreams: ['dataStream1'] }) }) + ); }); }); describe('detail panel', () => { test('opens when the data stream name in the table is clicked', async () => { const { actions, findDetailPanel, findDetailPanelTitle } = testBed; + httpRequestsMockHelpers.setLoadDataStreamResponse('dataStream1'); await actions.clickNameAt(0); expect(findDetailPanel().length).toBe(1); expect(findDetailPanelTitle()).toBe('dataStream1'); @@ -315,13 +310,10 @@ describe('Data Streams tab', () => { await clickConfirmDelete(); - const { method, url, requestBody } = server.requests[server.requests.length - 1]; - - expect(method).toBe('POST'); - expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`); - expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({ - dataStreams: ['dataStream1'], - }); + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/delete_data_streams`, + expect.objectContaining({ body: JSON.stringify({ dataStreams: ['dataStream1'] }) }) + ); }); test('clicking index template name navigates to the index template details', async () => { @@ -358,9 +350,9 @@ describe('Data Streams tab', () => { const dataStreamPercentSign = createDataStreamPayload({ name: '%dataStream' }); setLoadDataStreamsResponse([dataStreamPercentSign]); - setLoadDataStreamResponse(dataStreamPercentSign); + setLoadDataStreamResponse(dataStreamPercentSign.name, dataStreamPercentSign); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -396,10 +388,11 @@ describe('Data Streams tab', () => { name: 'dataStream1', ilmPolicyName: 'my_ilm_policy', }); + setLoadDataStreamsResponse([dataStreamForDetailPanel]); - setLoadDataStreamResponse(dataStreamForDetailPanel); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -417,10 +410,11 @@ describe('Data Streams tab', () => { const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; const dataStreamForDetailPanel = createDataStreamPayload({ name: 'dataStream1' }); + setLoadDataStreamsResponse([dataStreamForDetailPanel]); - setLoadDataStreamResponse(dataStreamForDetailPanel); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -442,10 +436,11 @@ describe('Data Streams tab', () => { name: 'dataStream1', ilmPolicyName: 'my_ilm_policy', }); + setLoadDataStreamsResponse([dataStreamForDetailPanel]); - setLoadDataStreamResponse(dataStreamForDetailPanel); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: { locators: { @@ -476,9 +471,10 @@ describe('Data Streams tab', () => { }, }); const nonManagedDataStream = createDataStreamPayload({ name: 'non-managed-data-stream' }); + httpRequestsMockHelpers.setLoadDataStreamsResponse([managedDataStream, nonManagedDataStream]); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -520,9 +516,10 @@ describe('Data Streams tab', () => { name: 'hidden-data-stream', hidden: true, }); + httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -561,7 +558,7 @@ describe('Data Streams tab', () => { beforeEach(async () => { setLoadDataStreamsResponse([dataStreamWithDelete, dataStreamNoDelete]); - testBed = await setup({ history: createMemoryHistory(), url: urlServiceMock }); + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock }); await act(async () => { testBed.actions.goToDataStreamsList(); }); @@ -599,7 +596,7 @@ describe('Data Streams tab', () => { actions: { clickNameAt }, find, } = testBed; - setLoadDataStreamResponse(dataStreamWithDelete); + setLoadDataStreamResponse(dataStreamWithDelete.name, dataStreamWithDelete); await clickNameAt(1); expect(find('deleteDataStreamButton').exists()).toBeTruthy(); @@ -610,7 +607,7 @@ describe('Data Streams tab', () => { actions: { clickNameAt }, find, } = testBed; - setLoadDataStreamResponse(dataStreamNoDelete); + setLoadDataStreamResponse(dataStreamNoDelete.name, dataStreamNoDelete); await clickNameAt(0); expect(find('deleteDataStreamButton').exists()).toBeFalsy(); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts index 46287fcdcf074..b73985dc8372b 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { IndexManagementHome } from '../../../public/application/sections/home'; import { indexManagementStore } from '../../../public/application/store'; import { WithAppDependencies, services, TestSubjects } from '../helpers'; @@ -19,8 +20,6 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(IndexManagementHome), testBedConfig); - export interface HomeTestBed extends TestBed { actions: { selectHomeTab: (tab: 'indicesTab' | 'templatesTab') => void; @@ -28,7 +27,11 @@ export interface HomeTestBed extends TestBed { }; } -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(IndexManagementHome, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); const { find } = testBed; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts index 60d4b7d3f2317..c3f8a5b17068d 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts @@ -20,18 +20,14 @@ import { stubWebWorker } from '@kbn/test-jest-helpers'; stubWebWorker(); describe('', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: HomeTestBed; - afterAll(() => { - server.restore(); - }); - describe('on component mount', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([]); - testBed = await setup(); + testBed = await setup(httpSetup); await act(async () => { const { component } = testBed; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts index 69dcabc287d6b..a16ba0768e675 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts @@ -13,6 +13,7 @@ import { AsyncTestBedConfig, findTestSubject, } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { TemplateList } from '../../../public/application/sections/home/template_list'; import { TemplateDeserialized } from '../../../common'; import { WithAppDependencies, TestSubjects } from '../helpers'; @@ -25,8 +26,6 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(TemplateList), testBedConfig); - const createActions = (testBed: TestBed) => { /** * Additional helpers @@ -132,7 +131,11 @@ const createActions = (testBed: TestBed) => { }; }; -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(TemplateList, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts index bf1a78e3cfe90..3d1360d620ff5 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts @@ -24,19 +24,15 @@ const removeWhiteSpaceOnArrayValues = (array: any[]) => }); describe('Index Templates tab', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: IndexTemplatesTabTestBed; - afterAll(() => { - server.restore(); - }); - describe('when there are no index templates of either kind', () => { test('should display an empty prompt', async () => { httpRequestsMockHelpers.setLoadTemplatesResponse({ templates: [], legacyTemplates: [] }); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); const { exists, component } = testBed; component.update(); @@ -54,7 +50,7 @@ describe('Index Templates tab', () => { }); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); const { exists, component } = testBed; component.update(); @@ -68,7 +64,8 @@ describe('Index Templates tab', () => { describe('when there are index templates', () => { // Add a default loadIndexTemplate response - httpRequestsMockHelpers.setLoadTemplateResponse(fixtures.getTemplate()); + const templateMock = fixtures.getTemplate(); + httpRequestsMockHelpers.setLoadTemplateResponse(templateMock.name, templateMock); const template1 = fixtures.getTemplate({ name: `a${getRandomString()}`, @@ -132,7 +129,7 @@ describe('Index Templates tab', () => { httpRequestsMockHelpers.setLoadTemplatesResponse({ templates, legacyTemplates }); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); }); @@ -194,7 +191,6 @@ describe('Index Templates tab', () => { test('should have a button to reload the index templates', async () => { const { exists, actions } = testBed; - const totalRequests = server.requests.length; expect(exists('reloadButton')).toBe(true); @@ -202,9 +198,9 @@ describe('Index Templates tab', () => { actions.clickReloadButton(); }); - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe( - `${API_BASE_PATH}/index_templates` + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates`, + expect.anything() ); }); @@ -235,6 +231,7 @@ describe('Index Templates tab', () => { const { find, exists, actions, component } = testBed; // Composable templates + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]); await actions.clickTemplateAt(0); expect(exists('templateList')).toBe(true); expect(exists('templateDetails')).toBe(true); @@ -246,6 +243,7 @@ describe('Index Templates tab', () => { }); component.update(); + httpRequestsMockHelpers.setLoadTemplateResponse(legacyTemplates[0].name, legacyTemplates[0]); await actions.clickTemplateAt(0, true); expect(exists('templateList')).toBe(true); @@ -380,13 +378,14 @@ describe('Index Templates tab', () => { confirmButton!.click(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - expect(latestRequest.method).toBe('POST'); - expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`); - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ - templates: [{ name: templates[0].name, isLegacy }], - }); + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/delete_index_templates`, + expect.objectContaining({ + body: JSON.stringify({ + templates: [{ name: templates[0].name, isLegacy }], + }), + }) + ); }); }); @@ -442,16 +441,14 @@ describe('Index Templates tab', () => { confirmButton!.click(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - expect(latestRequest.method).toBe('POST'); - expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`); - - // Commenting as I don't find a way to make it work. - // It keeps on returning the composable template instead of the legacy one - // expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ - // templates: [{ name: templateName, isLegacy }], - // }); + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/delete_index_templates`, + expect.objectContaining({ + body: JSON.stringify({ + templates: [{ name: templates[0].name, isLegacy: false }], + }), + }) + ); }); }); @@ -463,7 +460,7 @@ describe('Index Templates tab', () => { isLegacy: true, }); - httpRequestsMockHelpers.setLoadTemplateResponse(template); + httpRequestsMockHelpers.setLoadTemplateResponse(template.name, template); }); test('should show details when clicking on a template', async () => { @@ -471,6 +468,7 @@ describe('Index Templates tab', () => { expect(exists('templateDetails')).toBe(false); + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]); await actions.clickTemplateAt(0); expect(exists('templateDetails')).toBe(true); @@ -480,6 +478,7 @@ describe('Index Templates tab', () => { beforeEach(async () => { const { actions } = testBed; + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]); await actions.clickTemplateAt(0); }); @@ -544,7 +543,7 @@ describe('Index Templates tab', () => { const { find, actions, exists } = testBed; - httpRequestsMockHelpers.setLoadTemplateResponse(template); + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, template); httpRequestsMockHelpers.setSimulateTemplateResponse({ simulateTemplate: 'response' }); await actions.clickTemplateAt(0); @@ -598,8 +597,10 @@ describe('Index Templates tab', () => { const { actions, find, exists } = testBed; - httpRequestsMockHelpers.setLoadTemplateResponse(templateWithNoOptionalFields); - + httpRequestsMockHelpers.setLoadTemplateResponse( + templates[0].name, + templateWithNoOptionalFields + ); await actions.clickTemplateAt(0); expect(find('templateDetails.tab').length).toBe(5); @@ -621,13 +622,12 @@ describe('Index Templates tab', () => { it('should render an error message if error fetching template details', async () => { const { actions, exists } = testBed; const error = { - status: 404, + statusCode: 404, error: 'Not found', message: 'Template not found', }; - httpRequestsMockHelpers.setLoadTemplateResponse(undefined, { body: error }); - + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, undefined, error); await actions.clickTemplateAt(0); expect(exists('sectionError')).toBe(true); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts index 7daa3cc9e2221..5feb7840f259c 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts @@ -14,6 +14,7 @@ import { AsyncTestBedConfig, findTestSubject, } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { IndexManagementHome } from '../../../public/application/sections/home'; import { indexManagementStore } from '../../../public/application/store'; import { WithAppDependencies, services, TestSubjects } from '../helpers'; @@ -42,9 +43,12 @@ export interface IndicesTestBed extends TestBed { findDataStreamDetailPanelTitle: () => string; } -export const setup = async (overridingDependencies: any = {}): Promise => { +export const setup = async ( + httpSetup: HttpSetup, + overridingDependencies: any = {} +): Promise => { const initTestBed = registerTestBed( - WithAppDependencies(IndexManagementHome, overridingDependencies), + WithAppDependencies(IndexManagementHome, httpSetup, overridingDependencies), testBedConfig ); const testBed = await initTestBed(); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts index 8193d48629f6f..541f2b587b69f 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts @@ -49,22 +49,20 @@ stubWebWorker(); describe('', () => { let testBed: IndicesTestBed; - let server: ReturnType['server']; + let httpSetup: ReturnType['httpSetup']; let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; beforeEach(() => { - ({ server, httpRequestsMockHelpers } = setupEnvironment()); - }); - - afterAll(() => { - server.restore(); + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; }); describe('on component mount', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([]); - testBed = await setup(); + testBed = await setup(httpSetup); await act(async () => { const { component } = testBed; @@ -118,10 +116,11 @@ describe('', () => { httpRequestsMockHelpers.setLoadDataStreamsResponse([]); httpRequestsMockHelpers.setLoadDataStreamResponse( + 'dataStream1', createDataStreamPayload({ name: 'dataStream1' }) ); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), }); @@ -162,7 +161,7 @@ describe('', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([createNonDataStreamIndex(indexName)]); - testBed = await setup(); + testBed = await setup(httpSetup); const { component, find } = testBed; component.update(); @@ -174,32 +173,36 @@ describe('', () => { const { actions } = testBed; await actions.selectIndexDetailsTab('settings'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}` + ); }); test('should encode indexName when loading mappings in detail panel', async () => { const { actions } = testBed; await actions.selectIndexDetailsTab('mappings'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/mapping/${encodeURIComponent(indexName)}`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/mapping/${encodeURIComponent(indexName)}` + ); }); test('should encode indexName when loading stats in detail panel', async () => { const { actions } = testBed; await actions.selectIndexDetailsTab('stats'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/stats/${encodeURIComponent(indexName)}`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/stats/${encodeURIComponent(indexName)}` + ); }); test('should encode indexName when editing settings in detail panel', async () => { const { actions } = testBed; await actions.selectIndexDetailsTab('edit_settings'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}` + ); }); }); @@ -222,7 +225,7 @@ describe('', () => { ]); httpRequestsMockHelpers.setReloadIndicesResponse({ indexNames: [indexNameA, indexNameB] }); - testBed = await setup(); + testBed = await setup(httpSetup); const { component, find } = testBed; component.update(); @@ -236,8 +239,14 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('refreshIndexMenuButton'); - const latestRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/refresh`); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/refresh`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test('should be able to close an open index', async () => { @@ -246,13 +255,20 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('closeIndexMenuButton'); - // A refresh call was added after closing an index so we need to check the second to last request. - const latestRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/close`); + // After the index is closed, we imediately do a reload. So we need to expect to see + // a reload server call also. + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/close`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test('should be able to open a closed index', async () => { - testBed = await setup(); + testBed = await setup(httpSetup); const { component, find, actions } = testBed; component.update(); @@ -262,9 +278,16 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('openIndexMenuButton'); - // A refresh call was added after closing an index so we need to check the second to last request. - const latestRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/open`); + // After the index is opened, we imediately do a reload. So we need to expect to see + // a reload server call also. + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/open`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test('should be able to flush index', async () => { @@ -273,11 +296,16 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('flushIndexMenuButton'); - const requestsCount = server.requests.length; - expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/flush`); - // After the indices are flushed, we imediately reload them. So we need to expect to see + // After the index is flushed, we imediately do a reload. So we need to expect to see // a reload server call also. - expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/flush`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test("should be able to clear an index's cache", async () => { @@ -287,8 +315,16 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('clearCacheIndexMenuButton'); - const latestRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/clear_cache`); + // After the index cache is cleared, we imediately do a reload. So we need to expect to see + // a reload server call also. + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/clear_cache`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test('should be able to unfreeze a frozen index', async () => { @@ -302,11 +338,17 @@ describe('', () => { expect(exists('unfreezeIndexMenuButton')).toBe(true); await actions.clickContextMenuOption('unfreezeIndexMenuButton'); - const requestsCount = server.requests.length; - expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/unfreeze`); // After the index is unfrozen, we imediately do a reload. So we need to expect to see // a reload server call also. - expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/unfreeze`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); + // Open context menu once again, since clicking an action will close it. await actions.clickManageContextMenuButton(); // The unfreeze action should not be present anymore @@ -326,15 +368,33 @@ describe('', () => { await actions.clickModalConfirm(); - const requestsCount = server.requests.length; - expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/forcemerge`); - // After the index is force merged, we immediately do a reload. So we need to expect to see + // After the index force merged, we imediately do a reload. So we need to expect to see // a reload server call also. - expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/forcemerge`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); }); describe('Edit index settings', () => { + const indexName = 'test'; + + beforeEach(async () => { + httpRequestsMockHelpers.setLoadIndicesResponse([createNonDataStreamIndex(indexName)]); + + testBed = await setup(httpSetup); + const { component, find } = testBed; + + component.update(); + + find('indexTableIndexNameLink').at(0).simulate('click'); + }); + test('shows error callout when request fails', async () => { const { actions, find, component, exists } = testBed; @@ -347,7 +407,7 @@ describe('', () => { error: 'Bad Request', message: 'invalid tier names found in ...', }; - httpRequestsMockHelpers.setUpdateIndexSettingsResponse(undefined, error); + httpRequestsMockHelpers.setUpdateIndexSettingsResponse(indexName, undefined, error); await actions.selectIndexDetailsTab('edit_settings'); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts index 9aec6cae7a17e..2ee82c2b4c418 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts @@ -6,10 +6,11 @@ */ import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { TemplateClone } from '../../../public/application/sections/template_clone'; import { WithAppDependencies } from '../helpers'; -import { formSetup } from './template_form.helpers'; +import { formSetup, TestSubjects } from './template_form.helpers'; import { TEMPLATE_NAME } from './constants'; const testBedConfig: AsyncTestBedConfig = { @@ -20,6 +21,11 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(TemplateClone), testBedConfig); +export const setup = async (httpSetup: HttpSetup) => { + const initTestBed = registerTestBed( + WithAppDependencies(TemplateClone, httpSetup), + testBedConfig + ); -export const setup: any = formSetup.bind(null, initTestBed); + return formSetup(initTestBed); +}; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx index 31e65625cfdd0..861b1041a4f14 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import '../../../test/global_mocks'; +import { API_BASE_PATH } from '../../../common/constants'; import { getComposableTemplate } from '../../../test/fixtures'; import { setupEnvironment } from '../helpers'; @@ -44,23 +45,22 @@ const templateToClone = getComposableTemplate({ describe('', () => { let testBed: TemplateFormTestBed; - - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { jest.useFakeTimers(); + httpRequestsMockHelpers.setLoadTelemetryResponse({}); httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]); - httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone); + httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone.name, templateToClone); }); afterAll(() => { - server.restore(); jest.useRealTimers(); }); beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); }); @@ -98,17 +98,19 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - ...templateToClone, - name: `${templateToClone.name}-copy`, - indexPatterns: DEFAULT_INDEX_PATTERNS, - }; - - delete expected.template; // As no settings, mappings or aliases have been defined, no "template" param is sent - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + const { priority, version, _kbnMeta } = templateToClone; + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates`, + expect.objectContaining({ + body: JSON.stringify({ + name: `${templateToClone.name}-copy`, + indexPatterns: DEFAULT_INDEX_PATTERNS, + priority, + version, + _kbnMeta, + }), + }) + ); }); }); }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts index b039fa83000ed..e57e89a6762c2 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts @@ -6,12 +6,13 @@ */ import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { TemplateCreate } from '../../../public/application/sections/template_create'; import { WithAppDependencies } from '../helpers'; import { formSetup, TestSubjects } from './template_form.helpers'; -export const setup: any = (isLegacy: boolean = false) => { +export const setup = async (httpSetup: HttpSetup, isLegacy: boolean = false) => { const route = isLegacy ? { pathname: '/create_template', search: '?legacy=true' } : { pathname: '/create_template' }; @@ -25,9 +26,9 @@ export const setup: any = (isLegacy: boolean = false) => { }; const initTestBed = registerTestBed( - WithAppDependencies(TemplateCreate), + WithAppDependencies(TemplateCreate, httpSetup), testBedConfig ); - return formSetup.call(null, initTestBed); + return formSetup(initTestBed); }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx index 65d3678735689..078a171ac6a75 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import '../../../test/global_mocks'; +import { API_BASE_PATH } from '../../../common/constants'; import { setupEnvironment } from '../helpers'; import { @@ -76,7 +77,7 @@ const componentTemplates = [componentTemplate1, componentTemplate2]; describe('', () => { let testBed: TemplateFormTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { jest.useFakeTimers(); @@ -89,7 +90,6 @@ describe('', () => { }); afterAll(() => { - server.restore(); jest.useRealTimers(); (window as any)['__react-beautiful-dnd-disable-dev-warnings'] = false; }); @@ -97,7 +97,7 @@ describe('', () => { describe('composable index template', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); }); @@ -130,7 +130,7 @@ describe('', () => { describe('legacy index template', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(true); + testBed = await setup(httpSetup, true); }); }); @@ -150,7 +150,7 @@ describe('', () => { describe('form validation', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); }); @@ -367,7 +367,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadNodesPluginsResponse(['mapper-size']); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); await navigateToMappingsStep(); @@ -415,7 +415,7 @@ describe('', () => { describe('review (step 6)', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -472,7 +472,7 @@ describe('', () => { it('should render a warning message if a wildcard is used as an index pattern', async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -505,7 +505,7 @@ describe('', () => { const MAPPING_FIELDS = [BOOLEAN_MAPPING_FIELD, TEXT_MAPPING_FIELD, KEYWORD_MAPPING_FIELD]; await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -534,49 +534,50 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - name: TEMPLATE_NAME, - indexPatterns: DEFAULT_INDEX_PATTERNS, - composedOf: ['test_component_template_1'], - template: { - settings: SETTINGS, - mappings: { - properties: { - [BOOLEAN_MAPPING_FIELD.name]: { - type: BOOLEAN_MAPPING_FIELD.type, - }, - [TEXT_MAPPING_FIELD.name]: { - type: TEXT_MAPPING_FIELD.type, - }, - [KEYWORD_MAPPING_FIELD.name]: { - type: KEYWORD_MAPPING_FIELD.type, + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates`, + expect.objectContaining({ + body: JSON.stringify({ + name: TEMPLATE_NAME, + indexPatterns: DEFAULT_INDEX_PATTERNS, + _kbnMeta: { + type: 'default', + hasDatastream: false, + isLegacy: false, + }, + composedOf: ['test_component_template_1'], + template: { + settings: SETTINGS, + mappings: { + properties: { + [BOOLEAN_MAPPING_FIELD.name]: { + type: BOOLEAN_MAPPING_FIELD.type, + }, + [TEXT_MAPPING_FIELD.name]: { + type: TEXT_MAPPING_FIELD.type, + }, + [KEYWORD_MAPPING_FIELD.name]: { + type: KEYWORD_MAPPING_FIELD.type, + }, + }, }, + aliases: ALIASES, }, - }, - aliases: ALIASES, - }, - _kbnMeta: { - type: 'default', - isLegacy: false, - hasDatastream: false, - }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + }), + }) + ); }); it('should surface the API errors from the put HTTP request', async () => { const { component, actions, find, exists } = testBed; const error = { - status: 409, + statusCode: 409, error: 'Conflict', message: `There is already a template with name '${TEMPLATE_NAME}'`, }; - httpRequestsMockHelpers.setCreateTemplateResponse(undefined, { body: error }); + httpRequestsMockHelpers.setCreateTemplateResponse(undefined, error); await act(async () => { actions.clickNextButton(); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts index a7f87d828eb23..97166970568d3 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { TemplateEdit } from '../../../public/application/sections/template_edit'; import { WithAppDependencies } from '../helpers'; @@ -20,6 +21,11 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(TemplateEdit), testBedConfig); +export const setup = async (httpSetup: HttpSetup) => { + const initTestBed = registerTestBed( + WithAppDependencies(TemplateEdit, httpSetup), + testBedConfig + ); -export const setup: any = formSetup.bind(null, initTestBed); + return formSetup(initTestBed); +}; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx index d4680e7663322..4b94cb92c83d0 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx @@ -10,6 +10,7 @@ import { act } from 'react-dom/test-utils'; import '../../../test/global_mocks'; import * as fixtures from '../../../test/fixtures'; +import { API_BASE_PATH } from '../../../common/constants'; import { setupEnvironment, kibanaVersion } from '../helpers'; import { TEMPLATE_NAME, SETTINGS, ALIASES, MAPPINGS as DEFAULT_MAPPING } from './constants'; @@ -48,7 +49,7 @@ jest.mock('@elastic/eui', () => { describe('', () => { let testBed: TemplateFormTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { jest.useFakeTimers(); @@ -56,7 +57,6 @@ describe('', () => { }); afterAll(() => { - server.restore(); jest.useRealTimers(); }); @@ -71,12 +71,12 @@ describe('', () => { }); beforeAll(() => { - httpRequestsMockHelpers.setLoadTemplateResponse(templateToEdit); + httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit); }); beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -117,24 +117,25 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - name: 'test', - indexPatterns: ['myPattern*'], - dataStream: { - hidden: true, - anyUnknownKey: 'should_be_kept', - }, - version: 1, - _kbnMeta: { - type: 'default', - isLegacy: false, - hasDatastream: true, - }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates/test`, + expect.objectContaining({ + body: JSON.stringify({ + name: 'test', + indexPatterns: ['myPattern*'], + version: 1, + dataStream: { + hidden: true, + anyUnknownKey: 'should_be_kept', + }, + _kbnMeta: { + type: 'default', + hasDatastream: true, + isLegacy: false, + }, + }), + }) + ); }); }); @@ -148,12 +149,12 @@ describe('', () => { }); beforeAll(() => { - httpRequestsMockHelpers.setLoadTemplateResponse(templateToEdit); + httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit); }); beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); }); @@ -225,40 +226,40 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - const { version } = templateToEdit; - - const expected = { - name: TEMPLATE_NAME, - version, - priority: 3, - indexPatterns: UPDATED_INDEX_PATTERN, - template: { - mappings: { - properties: { - [UPDATED_MAPPING_TEXT_FIELD_NAME]: { - type: 'text', - store: false, - index: true, - fielddata: false, - eager_global_ordinals: false, - index_phrases: false, - norms: true, - index_options: 'positions', + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`, + expect.objectContaining({ + body: JSON.stringify({ + name: TEMPLATE_NAME, + indexPatterns: UPDATED_INDEX_PATTERN, + priority: 3, + version: templateToEdit.version, + _kbnMeta: { + type: 'default', + hasDatastream: false, + isLegacy: templateToEdit._kbnMeta.isLegacy, + }, + template: { + settings: SETTINGS, + mappings: { + properties: { + [UPDATED_MAPPING_TEXT_FIELD_NAME]: { + type: 'text', + index: true, + eager_global_ordinals: false, + index_phrases: false, + norms: true, + fielddata: false, + store: false, + index_options: 'positions', + }, + }, }, + aliases: ALIASES, }, - }, - settings: SETTINGS, - aliases: ALIASES, - }, - _kbnMeta: { - type: 'default', - isLegacy: templateToEdit._kbnMeta.isLegacy, - hasDatastream: false, - }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + }), + }) + ); }); }); }); @@ -277,12 +278,12 @@ describe('', () => { }); beforeAll(() => { - httpRequestsMockHelpers.setLoadTemplateResponse(legacyTemplateToEdit); + httpRequestsMockHelpers.setLoadTemplateResponse('my_template', legacyTemplateToEdit); }); beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -305,24 +306,25 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - const { version, template, name, indexPatterns, _kbnMeta, order } = legacyTemplateToEdit; - const expected = { - name, - indexPatterns, - version, - order, - template: { - aliases: undefined, - mappings: template!.mappings, - settings: undefined, - }, - _kbnMeta, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`, + expect.objectContaining({ + body: JSON.stringify({ + name, + indexPatterns, + version, + order, + template: { + aliases: undefined, + mappings: template!.mappings, + settings: undefined, + }, + _kbnMeta, + }), + }) + ); }); }); } diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts index 57d0b282d351d..9a68fe41fce27 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts @@ -10,7 +10,7 @@ import { act } from 'react-dom/test-utils'; import { TestBed, SetupFunc } from '@kbn/test-jest-helpers'; import { TemplateDeserialized } from '../../../common'; -interface MappingField { +export interface MappingField { name: string; type: string; } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx index f3957e0cc15c9..81f43a1b46073 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx @@ -11,6 +11,7 @@ import { act } from 'react-dom/test-utils'; import '../../../../../../../../../src/plugins/es_ui_shared/public/components/code_editor/jest_mock'; import '../../../../../../test/global_mocks'; import { setupEnvironment } from './helpers'; +import { API_BASE_PATH } from './helpers/constants'; import { setup, ComponentTemplateCreateTestBed } from './helpers/component_template_create.helpers'; jest.mock('@elastic/eui', () => { @@ -34,16 +35,12 @@ jest.mock('@elastic/eui', () => { describe('', () => { let testBed: ComponentTemplateCreateTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); - }); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); describe('On component mount', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -108,7 +105,7 @@ describe('', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); const { actions, component } = testBed; @@ -164,37 +161,38 @@ describe('', () => { component.update(); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - name: COMPONENT_TEMPLATE_NAME, - template: { - settings: SETTINGS, - mappings: { - properties: { - [BOOLEAN_MAPPING_FIELD.name]: { - type: BOOLEAN_MAPPING_FIELD.type, + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/component_templates`, + expect.objectContaining({ + body: JSON.stringify({ + name: COMPONENT_TEMPLATE_NAME, + template: { + settings: SETTINGS, + mappings: { + properties: { + [BOOLEAN_MAPPING_FIELD.name]: { + type: BOOLEAN_MAPPING_FIELD.type, + }, + }, }, + aliases: ALIASES, }, - }, - aliases: ALIASES, - }, - _kbnMeta: { usedBy: [], isManaged: false }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + _kbnMeta: { usedBy: [], isManaged: false }, + }), + }) + ); }); test('should surface API errors if the request is unsuccessful', async () => { const { component, actions, find, exists } = testBed; const error = { - status: 409, + statusCode: 409, error: 'Conflict', message: `There is already a template with name '${COMPONENT_TEMPLATE_NAME}'`, }; - httpRequestsMockHelpers.setCreateComponentTemplateResponse(undefined, { body: error }); + httpRequestsMockHelpers.setCreateComponentTemplateResponse(undefined, error); await act(async () => { actions.clickNextButton(); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts index 36ea2c27ec4fe..95495af1272c3 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts @@ -32,19 +32,18 @@ const COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS: ComponentTemplateDeserialized = { }; describe('', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: ComponentTemplateDetailsTestBed; - afterAll(() => { - server.restore(); - }); - describe('With component template details', () => { beforeEach(async () => { - httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE); + httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE.name, + COMPONENT_TEMPLATE + ); await act(async () => { - testBed = setup({ + testBed = setup(httpSetup, { componentTemplateName: COMPONENT_TEMPLATE.name, onClose: () => {}, }); @@ -104,11 +103,12 @@ describe('', () => { describe('With only required component template fields', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS.name, COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS ); await act(async () => { - testBed = setup({ + testBed = setup(httpSetup, { componentTemplateName: COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS.name, onClose: () => {}, }); @@ -156,10 +156,13 @@ describe('', () => { describe('With actions', () => { beforeEach(async () => { - httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE); + httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE.name, + COMPONENT_TEMPLATE + ); await act(async () => { - testBed = setup({ + testBed = setup(httpSetup, { componentTemplateName: COMPONENT_TEMPLATE.name, onClose: () => {}, actions: [ @@ -197,16 +200,20 @@ describe('', () => { describe('Error handling', () => { const error = { - status: 500, + statusCode: 500, error: 'Internal server error', message: 'Internal server error', }; beforeEach(async () => { - httpRequestsMockHelpers.setLoadComponentTemplateResponse(undefined, { body: error }); + httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE.name, + undefined, + error + ); await act(async () => { - testBed = setup({ + testBed = setup(httpSetup, { componentTemplateName: COMPONENT_TEMPLATE.name, onClose: () => {}, }); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx index 1f4abac806276..f3b5b52fe2c41 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx @@ -10,6 +10,7 @@ import { act } from 'react-dom/test-utils'; import '../../../../../../test/global_mocks'; import { setupEnvironment } from './helpers'; +import { API_BASE_PATH } from './helpers/constants'; import { setup, ComponentTemplateEditTestBed } from './helpers/component_template_edit.helpers'; jest.mock('@elastic/eui', () => { @@ -33,11 +34,7 @@ jest.mock('@elastic/eui', () => { describe('', () => { let testBed: ComponentTemplateEditTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); - }); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); const COMPONENT_TEMPLATE_NAME = 'comp-1'; const COMPONENT_TEMPLATE_TO_EDIT = { @@ -49,10 +46,13 @@ describe('', () => { }; beforeEach(async () => { - httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE_TO_EDIT); + httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE_TO_EDIT.name, + COMPONENT_TEMPLATE_TO_EDIT + ); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -98,17 +98,18 @@ describe('', () => { component.update(); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - version: 1, - ...COMPONENT_TEMPLATE_TO_EDIT, - template: { - ...COMPONENT_TEMPLATE_TO_EDIT.template, - }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/component_templates/${COMPONENT_TEMPLATE_TO_EDIT.name}`, + expect.objectContaining({ + body: JSON.stringify({ + ...COMPONENT_TEMPLATE_TO_EDIT, + template: { + ...COMPONENT_TEMPLATE_TO_EDIT.template, + }, + version: 1, + }), + }) + ); }); }); }); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts index dee15f2ae3a45..a3e9524dcd3ca 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts @@ -16,16 +16,12 @@ import { API_BASE_PATH } from './helpers/constants'; const { setup } = pageHelpers.componentTemplateList; describe('', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: ComponentTemplateListTestBed; - afterAll(() => { - server.restore(); - }); - beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -69,7 +65,6 @@ describe('', () => { test('should reload the component templates data', async () => { const { component, actions } = testBed; - const totalRequests = server.requests.length; await act(async () => { actions.clickReloadButton(); @@ -77,9 +72,9 @@ describe('', () => { component.update(); - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe( - `${API_BASE_PATH}/component_templates` + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/component_templates`, + expect.anything() ); }); @@ -103,7 +98,7 @@ describe('', () => { expect(modal).not.toBe(null); expect(modal!.textContent).toContain('Delete component template'); - httpRequestsMockHelpers.setDeleteComponentTemplateResponse({ + httpRequestsMockHelpers.setDeleteComponentTemplateResponse(componentTemplateName, { itemsDeleted: [componentTemplateName], errors: [], }); @@ -114,13 +109,10 @@ describe('', () => { component.update(); - const deleteRequest = server.requests[server.requests.length - 2]; - - expect(deleteRequest.method).toBe('DELETE'); - expect(deleteRequest.url).toBe( - `${API_BASE_PATH}/component_templates/${componentTemplateName}` + expect(httpSetup.delete).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/component_templates/${componentTemplateName}`, + expect.anything() ); - expect(deleteRequest.status).toEqual(200); }); }); @@ -129,7 +121,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -147,15 +139,15 @@ describe('', () => { describe('Error handling', () => { beforeEach(async () => { const error = { - status: 500, + statusCode: 500, error: 'Internal server error', message: 'Internal server error', }; - httpRequestsMockHelpers.setLoadComponentTemplatesResponse(undefined, { body: error }); + httpRequestsMockHelpers.setLoadComponentTemplatesResponse(undefined, error); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts index 18b5bbfd775bb..846c921e776c3 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { BASE_PATH } from '../../../../../../../common'; import { ComponentTemplateCreate } from '../../../component_template_wizard'; @@ -27,9 +28,11 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateCreate), testBedConfig); - -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(ComponentTemplateCreate, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts index cdf376028ff1d..18fe2b59f21c6 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, TestBed } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { WithAppDependencies } from './setup_environment'; import { ComponentTemplateDetailsFlyoutContent } from '../../../component_template_details'; @@ -43,9 +44,9 @@ const createActions = (testBed: TestBed) = }; }; -export const setup = (props: any): ComponentTemplateDetailsTestBed => { +export const setup = (httpSetup: HttpSetup, props: any): ComponentTemplateDetailsTestBed => { const setupTestBed = registerTestBed( - WithAppDependencies(ComponentTemplateDetailsFlyoutContent), + WithAppDependencies(ComponentTemplateDetailsFlyoutContent, httpSetup), { memoryRouter: { wrapComponent: false, diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts index 6e0f9d55ef7f0..dfc73e0ccafb0 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { BASE_PATH } from '../../../../../../../common'; import { ComponentTemplateEdit } from '../../../component_template_wizard'; @@ -27,9 +28,11 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateEdit), testBedConfig); - -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(ComponentTemplateEdit, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts index 2a01518e25466..3005eae0d6bf1 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts @@ -6,6 +6,7 @@ */ import { act } from 'react-dom/test-utils'; +import { HttpSetup } from 'src/core/public'; import { registerTestBed, @@ -26,8 +27,6 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateList), testBedConfig); - export type ComponentTemplateListTestBed = TestBed & { actions: ReturnType; }; @@ -74,7 +73,11 @@ const createActions = (testBed: TestBed) => { }; }; -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(ComponentTemplateList, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts index 520da90c58862..025f34066908c 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts @@ -5,65 +5,74 @@ * 2.0. */ -import sinon, { SinonFakeServer } from 'sinon'; -import { - ComponentTemplateListItem, - ComponentTemplateDeserialized, - ComponentTemplateSerialized, -} from '../../../shared_imports'; +import { httpServiceMock } from '../../../../../../../../../../src/core/public/mocks'; import { API_BASE_PATH } from './constants'; +type HttpResponse = Record | any[]; +type HttpMethod = 'GET' | 'PUT' | 'DELETE' | 'POST'; + +export interface ResponseError { + statusCode: number; + message: string | Error; + attributes?: Record; +} + // Register helpers to mock HTTP Requests -const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { - const setLoadComponentTemplatesResponse = ( - response?: ComponentTemplateListItem[], - error?: any - ) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; +const registerHttpRequestMockHelpers = ( + httpSetup: ReturnType +) => { + const mockResponses = new Map>>( + ['GET', 'PUT', 'DELETE', 'POST'].map( + (method) => [method, new Map()] as [HttpMethod, Map>] + ) + ); - server.respondWith('GET', `${API_BASE_PATH}/component_templates`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); + const mockMethodImplementation = (method: HttpMethod, path: string) => { + return mockResponses.get(method)?.get(path) ?? Promise.resolve({}); }; - const setLoadComponentTemplateResponse = ( - response?: ComponentTemplateDeserialized, - error?: any - ) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; + httpSetup.get.mockImplementation((path) => + mockMethodImplementation('GET', path as unknown as string) + ); + httpSetup.delete.mockImplementation((path) => + mockMethodImplementation('DELETE', path as unknown as string) + ); + httpSetup.post.mockImplementation((path) => + mockMethodImplementation('POST', path as unknown as string) + ); + httpSetup.put.mockImplementation((path) => + mockMethodImplementation('PUT', path as unknown as string) + ); - server.respondWith('GET', `${API_BASE_PATH}/component_templates/:name`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; + const mockResponse = (method: HttpMethod, path: string, response?: unknown, error?: unknown) => { + const defuse = (promise: Promise) => { + promise.catch(() => {}); + return promise; + }; - const setDeleteComponentTemplateResponse = (response?: object) => { - server.respondWith('DELETE', `${API_BASE_PATH}/component_templates/:name`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); + return mockResponses + .get(method)! + .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response)); }; - const setCreateComponentTemplateResponse = ( - response?: ComponentTemplateSerialized, - error?: any - ) => { - const status = error ? error.body.status || 400 : 200; - const body = error ? JSON.stringify(error.body) : JSON.stringify(response); + const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/component_templates`, response, error); - server.respondWith('POST', `${API_BASE_PATH}/component_templates`, [ - status, - { 'Content-Type': 'application/json' }, - body, - ]); - }; + const setLoadComponentTemplateResponse = ( + templateId: string, + response?: HttpResponse, + error?: ResponseError + ) => mockResponse('GET', `${API_BASE_PATH}/component_templates/${templateId}`, response, error); + + const setDeleteComponentTemplateResponse = ( + templateId: string, + response?: HttpResponse, + error?: ResponseError + ) => + mockResponse('DELETE', `${API_BASE_PATH}/component_templates/${templateId}`, response, error); + + const setCreateComponentTemplateResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/component_templates`, response, error); return { setLoadComponentTemplatesResponse, @@ -74,18 +83,11 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { }; export const init = () => { - const server = sinon.fakeServer.create(); - server.respondImmediately = true; - - // Define default response for unhandled requests. - // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry, - // and we can mock them all with a 200 instead of mocking each one individually. - server.respondWith([200, {}, 'DefaultMockedResponse']); - - const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server); + const httpSetup = httpServiceMock.createSetupContract(); + const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup); return { - server, + httpSetup, httpRequestsMockHelpers, }; }; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx index d532eaaba8923..9c2017ad651f1 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx @@ -6,8 +6,6 @@ */ import React from 'react'; -import axios from 'axios'; -import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { HttpSetup } from 'kibana/public'; import { @@ -24,7 +22,6 @@ import { ComponentTemplatesProvider } from '../../../component_templates_context import { init as initHttpRequests } from './http_requests'; import { API_BASE_PATH } from './constants'; -const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); const { GlobalFlyoutProvider } = GlobalFlyout; // We provide the minimum deps required to make the tests pass @@ -32,30 +29,23 @@ const appDependencies = { docLinks: {} as any, } as any; -export const componentTemplatesDependencies = { - httpClient: mockHttpClient as unknown as HttpSetup, +export const componentTemplatesDependencies = (httpSetup: HttpSetup) => ({ + httpClient: httpSetup, apiBasePath: API_BASE_PATH, trackMetric: () => {}, docLinks: docLinksServiceMock.createStartContract(), toasts: notificationServiceMock.createSetupContract().toasts, setBreadcrumbs: () => {}, getUrlForApp: applicationServiceMock.createStartContract().getUrlForApp, -}; +}); -export const setupEnvironment = () => { - const { server, httpRequestsMockHelpers } = initHttpRequests(); +export const setupEnvironment = initHttpRequests; - return { - server, - httpRequestsMockHelpers, - }; -}; - -export const WithAppDependencies = (Comp: any) => (props: any) => +export const WithAppDependencies = (Comp: any, httpSetup: HttpSetup) => (props: any) => ( - + diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 1504e33ecacab..d0bfecbd386be 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -45,6 +45,7 @@ export const LegendDisplay = { export const layerTypes = { DATA: 'data', REFERENCELINE: 'referenceLine', + ANNOTATIONS: 'annotations', } as const; // might collide with user-supplied field names, try to make as unique as possible diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts new file mode 100644 index 0000000000000..45b4bf31c0cdc --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts @@ -0,0 +1,67 @@ +/* + * 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 { + EventAnnotationConfig, + EventAnnotationOutput, +} from '../../../../../../../src/plugins/event_annotation/common'; +import type { ExpressionFunctionDefinition } from '../../../../../../../src/plugins/expressions/common'; +import { layerTypes } from '../../../constants'; + +export interface XYAnnotationLayerConfig { + layerId: string; + layerType: typeof layerTypes.ANNOTATIONS; + annotations: EventAnnotationConfig[]; + hide?: boolean; +} + +export interface AnnotationLayerArgs { + annotations: EventAnnotationOutput[]; + layerId: string; + layerType: typeof layerTypes.ANNOTATIONS; + hide?: boolean; +} +export type XYAnnotationLayerArgsResult = AnnotationLayerArgs & { + type: 'lens_xy_annotation_layer'; +}; +export function annotationLayerConfig(): ExpressionFunctionDefinition< + 'lens_xy_annotation_layer', + null, + AnnotationLayerArgs, + XYAnnotationLayerArgsResult +> { + return { + name: 'lens_xy_annotation_layer', + aliases: [], + type: 'lens_xy_annotation_layer', + inputTypes: ['null'], + help: 'Annotation layer in lens', + args: { + layerId: { + types: ['string'], + help: '', + }, + layerType: { types: ['string'], options: [layerTypes.ANNOTATIONS], help: '' }, + hide: { + types: ['boolean'], + default: false, + help: 'Show details', + }, + annotations: { + types: ['manual_event_annotation'], + help: '', + multi: true, + }, + }, + fn: (input, args) => { + return { + type: 'lens_xy_annotation_layer', + ...args, + }; + }, + }; +} diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts index 0b27ce7d6ed85..df27229bdb81f 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts @@ -6,7 +6,12 @@ */ import { XYDataLayerConfig } from './data_layer_config'; import { XYReferenceLineLayerConfig } from './reference_line_layer_config'; +import { XYAnnotationLayerConfig } from './annotation_layer_config'; export * from './data_layer_config'; export * from './reference_line_layer_config'; +export * from './annotation_layer_config'; -export type XYLayerConfig = XYDataLayerConfig | XYReferenceLineLayerConfig; +export type XYLayerConfig = + | XYDataLayerConfig + | XYReferenceLineLayerConfig + | XYAnnotationLayerConfig; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts index 940896a2079e6..4520f0c99c3e9 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts @@ -9,13 +9,14 @@ import type { AxisExtentConfigResult, AxisTitlesVisibilityConfigResult } from '. import type { FittingFunction } from './fitting_function'; import type { EndValue } from './end_value'; import type { GridlinesConfigResult } from './grid_lines_config'; -import type { DataLayerArgs } from './layer_config'; +import type { AnnotationLayerArgs, DataLayerArgs } from './layer_config'; import type { LegendConfigResult } from './legend_config'; import type { TickLabelsConfigResult } from './tick_labels_config'; import type { LabelsOrientationConfigResult } from './labels_orientation_config'; import type { ValueLabelConfig } from '../../types'; export type XYCurveType = 'LINEAR' | 'CURVE_MONOTONE_X'; +export type XYLayerArgs = DataLayerArgs | AnnotationLayerArgs; // Arguments to XY chart expression, with computed properties export interface XYArgs { @@ -28,7 +29,7 @@ export interface XYArgs { yRightExtent: AxisExtentConfigResult; legend: LegendConfigResult; valueLabels: ValueLabelConfig; - layers: DataLayerArgs[]; + layers: XYLayerArgs[]; fittingFunction?: FittingFunction; endValue?: EndValue; emphasizeFitting?: boolean; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts index d0f278d382be9..6d73e8eb9ba5f 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts @@ -128,8 +128,12 @@ export const xyChart: ExpressionFunctionDefinition< }), }, layers: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - types: ['lens_xy_data_layer', 'lens_xy_referenceLine_layer'] as any, + types: [ + 'lens_xy_data_layer', + 'lens_xy_referenceLine_layer', + 'lens_xy_annotation_layer', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any, help: 'Layers of visual series', multi: true, }, diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 17a58a0f96770..18f33adf40840 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -21,7 +21,8 @@ "presentationUtil", "dataViewFieldEditor", "expressionGauge", - "expressionHeatmap" + "expressionHeatmap", + "eventAnnotation" ], "optionalPlugins": [ "usageCollection", diff --git a/x-pack/plugins/lens/public/app_plugin/app.scss b/x-pack/plugins/lens/public/app_plugin/app.scss index 83b0a39be9229..5e859c1a93818 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.scss +++ b/x-pack/plugins/lens/public/app_plugin/app.scss @@ -38,6 +38,13 @@ fill: makeGraphicContrastColor($euiColorVis0, $euiColorDarkShade); } } +.lensAnnotationIconNoFill { + fill: none; +} + +.lensAnnotationIconFill { + fill: $euiColorGhost; +} // Less-than-ideal styles to add a vertical divider after this button. Consider restructuring markup for better semantics and styling options in the future. .lnsNavItem__goBack { diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx new file mode 100644 index 0000000000000..fe19dc7e4c8fc --- /dev/null +++ b/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx @@ -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 * as React from 'react'; +import { EuiIconProps } from '@elastic/eui'; +import classnames from 'classnames'; + +export const IconCircle = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + +); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx similarity index 73% rename from x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx rename to x-pack/plugins/lens/public/assets/annotation_icons/index.tsx index 8473d3971c66f..9e641d495582f 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx +++ b/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx @@ -5,6 +5,5 @@ * 2.0. */ -export * from './authentications'; -export * from './hosts'; -export * from './unique_ips'; +export { IconCircle } from './circle'; +export { IconTriangle } from './triangle'; diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx new file mode 100644 index 0000000000000..9924c049004cf --- /dev/null +++ b/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx @@ -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 * as React from 'react'; +import { EuiIconProps } from '@elastic/eui'; +import classnames from 'classnames'; + +export const IconTriangle = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + +); diff --git a/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx b/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx new file mode 100644 index 0000000000000..63fc9023533f6 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIconProps } from '@elastic/eui'; + +export const LensIconChartBarAnnotations = ({ + title, + titleId, + ...props +}: Omit) => ( + + {title ? {title} : null} + + + + + +); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx index e88b04588d2e0..f0e0911b708fd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx @@ -18,6 +18,7 @@ import { getCustomDropTarget, getAdditionalClassesOnDroppable, getAdditionalClassesOnEnter, + getDropProps, } from './drop_targets_utils'; export function DraggableDimensionButton({ @@ -59,8 +60,8 @@ export function DraggableDimensionButton({ }) { const { dragging } = useContext(DragContext); - const dropProps = layerDatasource.getDropProps({ - ...layerDatasourceDropProps, + const dropProps = getDropProps(layerDatasource, { + ...(layerDatasourceDropProps || {}), dragging, columnId, filterOperations: group.filterOperations, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx index 7d92eb9d22cbb..a293af4d11bfe 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx @@ -9,7 +9,7 @@ import React from 'react'; import classNames from 'classnames'; import { EuiIcon, EuiFlexItem, EuiFlexGroup, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { DropType } from '../../../../types'; +import { Datasource, DropType, GetDropProps } from '../../../../types'; function getPropsForDropType(type: 'swap' | 'duplicate' | 'combine') { switch (type) { @@ -129,3 +129,13 @@ export const getAdditionalClassesOnDroppable = (dropType?: string) => { return 'lnsDragDrop-notCompatible'; } }; + +export const getDropProps = ( + layerDatasource: Datasource, + layerDatasourceDropProps: GetDropProps +) => { + if (layerDatasource) { + return layerDatasource.getDropProps(layerDatasourceDropProps); + } + return; +}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx index 1ba3ff8f6ac34..f2118bda216b8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx @@ -14,7 +14,11 @@ import { DragDrop, DragDropIdentifier, DragContext } from '../../../../drag_drop import { Datasource, VisualizationDimensionGroupConfig, DropType } from '../../../../types'; import { LayerDatasourceDropProps } from '../types'; -import { getCustomDropTarget, getAdditionalClassesOnDroppable } from './drop_targets_utils'; +import { + getCustomDropTarget, + getAdditionalClassesOnDroppable, + getDropProps, +} from './drop_targets_utils'; const label = i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', { defaultMessage: 'Empty dimension', @@ -24,32 +28,47 @@ interface EmptyButtonProps { columnId: string; onClick: (id: string) => void; group: VisualizationDimensionGroupConfig; + labels?: { + ariaLabel: (label: string) => string; + label: JSX.Element | string; + }; } -const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => ( - + i18n.translate('xpack.lens.indexPattern.addColumnAriaLabel', { defaultMessage: 'Add or drag-and-drop a field to {groupLabel}', - values: { groupLabel: group.groupLabel }, - })} - data-test-subj="lns-empty-dimension" - onClick={() => { - onClick(columnId); - }} - > + values: { groupLabel: l }, + }), + label: ( - -); + ), +}; + +const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => { + const { buttonAriaLabel, buttonLabel } = group.labels || {}; + return ( + { + onClick(columnId); + }} + > + {buttonLabel || defaultButtonLabels.label} + + ); +}; const SuggestedValueButton = ({ columnId, group, onClick }: EmptyButtonProps) => ( contentProps={{ className: 'lnsLayerPanel__triggerTextContent', }} - aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnAriaLabel', { - defaultMessage: 'Add or drag-and-drop a field to {groupLabel}', - values: { groupLabel: group.groupLabel }, + aria-label={i18n.translate('xpack.lens.indexPattern.suggestedValueAriaLabel', { + defaultMessage: 'Suggested value: {value} for {groupLabel}', + values: { value: group.suggestedValue?.(), groupLabel: group.groupLabel }, })} data-test-subj="lns-empty-dimension-suggested-value" onClick={() => { @@ -112,8 +131,8 @@ export function EmptyDimensionButton({ setNewColumnId(generateId()); }, [itemIndex]); - const dropProps = layerDatasource.getDropProps({ - ...layerDatasourceDropProps, + const dropProps = getDropProps(layerDatasource, { + ...(layerDatasourceDropProps || {}), dragging, columnId: newColumnId, filterOperations: group.filterOperations, @@ -151,6 +170,12 @@ export function EmptyDimensionButton({ [value, onDrop] ); + const buttonProps: EmptyButtonProps = { + columnId: value.columnId, + onClick, + group, + }; + return (
{typeof group.suggestedValue?.() === 'number' ? ( - + ) : ( - + )}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx index cd26cd3197587..b234b18f5262f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx @@ -20,7 +20,7 @@ import { LayerPanel } from './layer_panel'; import { coreMock } from 'src/core/public/mocks'; import { generateId } from '../../../id_generator'; import { mountWithProvider } from '../../../mocks'; -import { layerTypes } from '../../../../common'; +import { LayerType, layerTypes } from '../../../../common'; import { ReactWrapper } from 'enzyme'; import { addLayer } from '../../../state_management'; @@ -231,14 +231,17 @@ describe('ConfigPanel', () => { }); describe('initial default value', () => { - function clickToAddLayer(instance: ReactWrapper) { + function clickToAddLayer( + instance: ReactWrapper, + layerType: LayerType = layerTypes.REFERENCELINE + ) { act(() => { instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click'); }); instance.update(); act(() => { instance - .find(`[data-test-subj="lnsLayerAddButton-${layerTypes.REFERENCELINE}"]`) + .find(`[data-test-subj="lnsLayerAddButton-${layerType}"]`) .first() .simulate('click'); }); @@ -288,8 +291,6 @@ describe('ConfigPanel', () => { { groupId: 'testGroup', columnId: 'myColumn', - dataType: 'number', - label: 'Initial value', staticValue: 100, }, ], @@ -319,8 +320,6 @@ describe('ConfigPanel', () => { { groupId: 'testGroup', columnId: 'myColumn', - dataType: 'number', - label: 'Initial value', staticValue: 100, }, ], @@ -335,9 +334,7 @@ describe('ConfigPanel', () => { expect(lensStore.dispatch).toHaveBeenCalledTimes(1); expect(datasourceMap.testDatasource.initializeDimension).toHaveBeenCalledWith({}, 'newId', { columnId: 'myColumn', - dataType: 'number', groupId: 'testGroup', - label: 'Initial value', staticValue: 100, }); }); @@ -354,8 +351,6 @@ describe('ConfigPanel', () => { { groupId: 'a', columnId: 'newId', - dataType: 'number', - label: 'Initial value', staticValue: 100, }, ], @@ -374,11 +369,65 @@ describe('ConfigPanel', () => { { groupId: 'a', columnId: 'newId', - dataType: 'number', - label: 'Initial value', staticValue: 100, } ); }); + + it('When visualization is `noDatasource` should not run datasource methods', async () => { + const datasourceMap = mockDatasourceMap(); + + const visualizationMap = mockVisualizationMap(); + visualizationMap.testVis.setDimension = jest.fn(); + visualizationMap.testVis.getSupportedLayers = jest.fn(() => [ + { + type: layerTypes.DATA, + label: 'Data Layer', + initialDimensions: [ + { + groupId: 'testGroup', + columnId: 'myColumn', + staticValue: 100, + }, + ], + }, + { + type: layerTypes.REFERENCELINE, + label: 'Reference layer', + }, + { + type: layerTypes.ANNOTATIONS, + label: 'Annotations Layer', + noDatasource: true, + initialDimensions: [ + { + groupId: 'a', + columnId: 'newId', + staticValue: 100, + }, + ], + }, + ]); + + datasourceMap.testDatasource.initializeDimension = jest.fn(); + const props = getDefaultProps({ visualizationMap, datasourceMap }); + const { instance, lensStore } = await prepareAndMountComponent(props); + await clickToAddLayer(instance, layerTypes.ANNOTATIONS); + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + + expect(visualizationMap.testVis.setDimension).toHaveBeenCalledWith({ + columnId: 'newId', + frame: { + activeData: undefined, + datasourceLayers: { + a: expect.anything(), + }, + }, + groupId: 'a', + layerId: 'newId', + prevState: undefined, + }); + expect(datasourceMap.testDatasource.initializeDimension).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index d3574abe4f57a..163d1b8ce8e61 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -135,61 +135,57 @@ export function LayerPanels( [dispatchLens] ); - const datasourcePublicAPIs = props.framePublicAPI.datasourceLayers; - return ( - {layerIds.map((layerId, layerIndex) => - datasourcePublicAPIs[layerId] ? ( - { - // avoid state update if the datasource does not support initializeDimension - if ( - activeDatasourceId != null && - datasourceMap[activeDatasourceId]?.initializeDimension - ) { - dispatchLens( - setLayerDefaultDimension({ - layerId, - columnId, - groupId, - }) - ); - } - }} - onRemoveLayer={() => { + {layerIds.map((layerId, layerIndex) => ( + { + // avoid state update if the datasource does not support initializeDimension + if ( + activeDatasourceId != null && + datasourceMap[activeDatasourceId]?.initializeDimension + ) { dispatchLens( - removeOrClearLayer({ - visualizationId: activeVisualization.id, + setLayerDefaultDimension({ layerId, - layerIds, + columnId, + groupId, }) ); - removeLayerRef(layerId); - }} - toggleFullscreen={toggleFullscreen} - /> - ) : null - )} + } + }} + onRemoveLayer={() => { + dispatchLens( + removeOrClearLayer({ + visualizationId: activeVisualization.id, + layerId, + layerIds, + }) + ); + removeLayerRef(layerId); + }} + toggleFullscreen={toggleFullscreen} + /> + ))} Hello!, + style: {}, + }, +}; + describe('LayerPanel', () => { let mockVisualization: jest.Mocked; let mockVisualization2: jest.Mocked; let mockDatasource: DatasourceMock; + mockDatasource = createMockDatasource('testDatasource'); let frame: FramePublicAPI; function getDefaultProps() { @@ -611,17 +623,6 @@ describe('LayerPanel', () => { nextLabel: '', }); - const draggingField = { - field: { name: 'dragged' }, - indexPatternId: 'a', - id: '1', - humanData: { label: 'Label' }, - ghost: { - children: , - style: {}, - }, - }; - const { instance } = await mountWithProvider( @@ -666,17 +667,6 @@ describe('LayerPanel', () => { columnId !== 'a' ? { dropTypes: ['field_replace'], nextLabel: '' } : undefined ); - const draggingField = { - field: { name: 'dragged' }, - indexPatternId: 'a', - id: '1', - humanData: { label: 'Label' }, - ghost: { - children: , - style: {}, - }, - }; - const { instance } = await mountWithProvider( @@ -985,4 +975,52 @@ describe('LayerPanel', () => { ); }); }); + describe('dimension trigger', () => { + it('should render datasource dimension trigger if there is layer datasource', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'x' }], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + ], + }); + await mountWithProvider(); + expect(mockDatasource.renderDimensionTrigger).toHaveBeenCalled(); + }); + + it('should render visualization dimension trigger if there is no layer datasource', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'x' }], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + const props = getDefaultProps(); + const propsWithVisOnlyLayer = { + ...props, + framePublicAPI: { ...props.framePublicAPI, datasourceLayers: {} }, + }; + + mockVisualization.renderDimensionTrigger = jest.fn(); + mockVisualization.getUniqueLabels = jest.fn(() => ({ + x: 'A', + })); + + await mountWithProvider(); + expect(mockDatasource.renderDimensionTrigger).not.toHaveBeenCalled(); + expect(mockVisualization.renderDimensionTrigger).toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 404a40832fc2f..366d3f93bf842 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -81,10 +81,10 @@ export function LayerPanel( updateDatasourceAsync, visualizationState, } = props; - const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; - const dateRange = useLensSelector(selectResolvedDateRange); + const datasourceStates = useLensSelector(selectDatasourceStates); const isFullscreen = useLensSelector(selectIsFullscreenDatasource); + const dateRange = useLensSelector(selectResolvedDateRange); useEffect(() => { setActiveDimension(initialActiveDimensionState); @@ -104,8 +104,10 @@ export function LayerPanel( activeData: props.framePublicAPI.activeData, }; - const datasourceId = datasourcePublicAPI.datasourceId; - const layerDatasourceState = datasourceStates[datasourceId].state; + const datasourcePublicAPI = framePublicAPI.datasourceLayers?.[layerId]; + const datasourceId = datasourcePublicAPI?.datasourceId; + const layerDatasourceState = datasourceStates?.[datasourceId]?.state; + const layerDatasource = props.datasourceMap[datasourceId]; const layerDatasourceDropProps = useMemo( () => ({ @@ -118,12 +120,9 @@ export function LayerPanel( [layerId, layerDatasourceState, datasourceId, updateDatasource] ); - const layerDatasource = props.datasourceMap[datasourceId]; - const layerDatasourceConfigProps = { ...layerDatasourceDropProps, frame: props.framePublicAPI, - activeData: props.framePublicAPI.activeData, dateRange, }; @@ -137,11 +136,15 @@ export function LayerPanel( activeVisualization, ] ); + + const columnLabelMap = + !layerDatasource && activeVisualization.getUniqueLabels + ? activeVisualization.getUniqueLabels(props.visualizationState) + : layerDatasource?.uniqueLabels?.(layerDatasourceConfigProps?.state); + const isEmptyLayer = !groups.some((d) => d.accessors.length > 0); const { activeId, activeGroup } = activeDimension; - const columnLabelMap = layerDatasource.uniqueLabels(layerDatasourceConfigProps.state); - const { setDimension, removeDimension } = activeVisualization; const allAccessors = groups.flatMap((group) => @@ -154,7 +157,7 @@ export function LayerPanel( registerNewRef: registerNewButtonRef, } = useFocusUpdate(allAccessors); - const layerDatasourceOnDrop = layerDatasource.onDrop; + const layerDatasourceOnDrop = layerDatasource?.onDrop; const onDrop = useMemo(() => { return ( @@ -180,16 +183,18 @@ export function LayerPanel( const filterOperations = group?.filterOperations || (() => false); - const dropResult = layerDatasourceOnDrop({ - ...layerDatasourceDropProps, - droppedItem, - columnId, - layerId: targetLayerId, - filterOperations, - dimensionGroups: groups, - groupId, - dropType, - }); + const dropResult = layerDatasource + ? layerDatasourceOnDrop({ + ...layerDatasourceDropProps, + droppedItem, + columnId, + layerId: targetLayerId, + filterOperations, + dimensionGroups: groups, + groupId, + dropType, + }) + : false; if (dropResult) { let previousColumn = typeof droppedItem.column === 'string' ? droppedItem.column : undefined; @@ -241,6 +246,7 @@ export function LayerPanel( removeDimension, layerDatasourceDropProps, setNextFocusedButtonId, + layerDatasource, ]); const isDimensionPanelOpen = Boolean(activeId); @@ -340,43 +346,45 @@ export function LayerPanel( /> - {layerDatasource && ( - { - const newState = - typeof updater === 'function' ? updater(layerDatasourceState) : updater; - // Look for removed columns - const nextPublicAPI = layerDatasource.getPublicAPI({ - state: newState, - layerId, - }); - const nextTable = new Set( - nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) - ); - const removed = datasourcePublicAPI - .getTableSpec() - .map(({ columnId }) => columnId) - .filter((columnId) => !nextTable.has(columnId)); - let nextVisState = props.visualizationState; - removed.forEach((columnId) => { - nextVisState = activeVisualization.removeDimension({ + <> + + { + const newState = + typeof updater === 'function' ? updater(layerDatasourceState) : updater; + // Look for removed columns + const nextPublicAPI = layerDatasource.getPublicAPI({ + state: newState, layerId, - columnId, - prevState: nextVisState, - frame: framePublicAPI, }); - }); + const nextTable = new Set( + nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) + ); + const removed = datasourcePublicAPI + .getTableSpec() + .map(({ columnId }) => columnId) + .filter((columnId) => !nextTable.has(columnId)); + let nextVisState = props.visualizationState; + removed.forEach((columnId) => { + nextVisState = activeVisualization.removeDimension({ + layerId, + columnId, + prevState: nextVisState, + frame: framePublicAPI, + }); + }); - props.updateAll(datasourceId, newState, nextVisState); - }, - }} - /> + props.updateAll(datasourceId, newState, nextVisState); + }, + }} + /> + )} @@ -401,7 +409,6 @@ export function LayerPanel( : i18n.translate('xpack.lens.editorFrame.requiresFieldWarningLabel', { defaultMessage: 'Requires field', }); - const isOptional = !group.required && !group.suggestedValue; return ( {group.accessors.map((accessorConfig, accessorIndex) => { const { columnId } = accessorConfig; - return ( { setActiveDimension({ @@ -478,42 +484,66 @@ export function LayerPanel( }} onRemoveClick={(id: string) => { trackUiEvent('indexpattern_dimension_removed'); - props.updateAll( - datasourceId, - layerDatasource.removeColumn({ - layerId, - columnId: id, - prevState: layerDatasourceState, - }), - activeVisualization.removeDimension({ - layerId, - columnId: id, - prevState: props.visualizationState, - frame: framePublicAPI, - }) - ); + if (datasourceId && layerDatasource) { + props.updateAll( + datasourceId, + layerDatasource.removeColumn({ + layerId, + columnId: id, + prevState: layerDatasourceState, + }), + activeVisualization.removeDimension({ + layerId, + columnId: id, + prevState: props.visualizationState, + frame: framePublicAPI, + }) + ); + } else { + props.updateVisualization( + activeVisualization.removeDimension({ + layerId, + columnId: id, + prevState: props.visualizationState, + frame: framePublicAPI, + }) + ); + } removeButtonRef(id); }} invalid={ - !layerDatasource.isValidColumn( + layerDatasource && + !layerDatasource?.isValidColumn( layerDatasourceState, layerId, columnId ) } > - + {layerDatasource ? ( + + ) : ( + <> + {activeVisualization?.renderDimensionTrigger?.({ + columnId, + label: columnLabelMap[columnId], + hideTooltip, + invalid: group.invalid, + invalidMessage: group.invalidMessage, + })} + + )}
@@ -536,7 +566,7 @@ export function LayerPanel( setActiveDimension({ activeGroup: group, activeId: id, - isNew: !group.supportStaticValue, + isNew: !group.supportStaticValue && Boolean(layerDatasource), }); }} onDrop={onDrop} @@ -555,22 +585,25 @@ export function LayerPanel( isFullscreen={isFullscreen} groupLabel={activeGroup?.groupLabel || ''} handleClose={() => { - if ( - layerDatasource.canCloseDimensionEditor && - !layerDatasource.canCloseDimensionEditor(layerDatasourceState) - ) { - return false; - } - if (layerDatasource.updateStateOnCloseDimension) { - const newState = layerDatasource.updateStateOnCloseDimension({ - state: layerDatasourceState, - layerId, - columnId: activeId!, - }); - if (newState) { - props.updateDatasource(datasourceId, newState); + if (layerDatasource) { + if ( + layerDatasource.canCloseDimensionEditor && + !layerDatasource.canCloseDimensionEditor(layerDatasourceState) + ) { + return false; + } + if (layerDatasource.updateStateOnCloseDimension) { + const newState = layerDatasource.updateStateOnCloseDimension({ + state: layerDatasourceState, + layerId, + columnId: activeId!, + }); + if (newState) { + props.updateDatasource(datasourceId, newState); + } } } + setActiveDimension(initialActiveDimensionState); if (isFullscreen) { toggleFullscreen(); @@ -579,7 +612,7 @@ export function LayerPanel( }} panel={
- {activeGroup && activeId && ( + {activeGroup && activeId && layerDatasource && ( - + - - color)} - type={FIXED_PROGRESSION} - onClick={() => { - setIsPaletteOpen(!isPaletteOpen); - }} - /> - - - { - setIsPaletteOpen(!isPaletteOpen); - }} - size="xs" - flush="both" - > - {i18n.translate('xpack.lens.paletteHeatmapGradient.customize', { - defaultMessage: 'Edit', - })} - - setIsPaletteOpen(!isPaletteOpen)} - > - {activePalette && ( - { - // make sure to always have a list of stops - if (newPalette.params && !newPalette.params.stops) { - newPalette.params.stops = displayStops; - } - (newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor; - setState({ - ...state, - palette: newPalette as HeatmapVisualizationState['palette'], - }); - }} - /> - )} - - - - + + + color)} + type={FIXED_PROGRESSION} + onClick={() => { + setIsPaletteOpen(!isPaletteOpen); + }} + /> + + + { + setIsPaletteOpen(!isPaletteOpen); + }} + size="xs" + flush="both" + > + {i18n.translate('xpack.lens.paletteHeatmapGradient.customize', { + defaultMessage: 'Edit', + })} + + setIsPaletteOpen(!isPaletteOpen)} + > + {activePalette && ( + { + // make sure to always have a list of stops + if (newPalette.params && !newPalette.params.stops) { + newPalette.params.stops = displayStops; + } + (newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor; + setState({ + ...state, + palette: newPalette as HeatmapVisualizationState['palette'], + }); + }} + /> + )} + + + + + ); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts index f3c48bace4a5f..3318b8c30909e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts @@ -89,12 +89,13 @@ export function getDropProps(props: GetDropProps) { ) { const sourceColumn = state.layers[dragging.layerId].columns[dragging.columnId]; const targetColumn = state.layers[layerId].columns[columnId]; - const layerIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId]; - const isSameGroup = groupId === dragging.groupId; if (isSameGroup) { - return getDropPropsForSameGroup(targetColumn); - } else if (filterOperations(sourceColumn)) { + return getDropPropsForSameGroup(!targetColumn); + } + const layerIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId]; + + if (filterOperations(sourceColumn)) { return getDropPropsForCompatibleGroup( props.dimensionGroups, dragging.columnId, @@ -164,8 +165,8 @@ function getDropPropsForField({ return; } -function getDropPropsForSameGroup(targetColumn?: GenericIndexPatternColumn): DropProps { - return targetColumn ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] }; +function getDropPropsForSameGroup(isNew?: boolean): DropProps { + return !isNew ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] }; } function getDropPropsForCompatibleGroup( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index f19658d468d5f..6bdd41d8db631 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -2626,9 +2626,7 @@ describe('IndexPattern Data Source', () => { expect( indexPatternDatasource.initializeDimension!(state, 'first', { columnId: 'newStatic', - label: 'MyNewColumn', groupId: 'a', - dataType: 'number', }) ).toBe(state); }); @@ -2655,9 +2653,7 @@ describe('IndexPattern Data Source', () => { expect( indexPatternDatasource.initializeDimension!(state, 'first', { columnId: 'newStatic', - label: 'MyNewColumn', groupId: 'a', - dataType: 'number', staticValue: 0, // use a falsy value to check also this corner case }) ).toEqual({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index cf77d1c9c1cc2..d0b644e2bf9b4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -230,7 +230,7 @@ export function getIndexPatternDatasource({ }); }, - initializeDimension(state, layerId, { columnId, groupId, label, dataType, staticValue }) { + initializeDimension(state, layerId, { columnId, groupId, staticValue }) { const indexPattern = state.indexPatterns[state.layers[layerId]?.indexPatternId]; if (staticValue == null) { return state; diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 2c038b0937999..d1f16ac5f9c41 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -22,8 +22,12 @@ import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PartitionChartsMeta } from './partition_charts_meta'; import { LegendDisplay, PieVisualizationState, SharedPieLayerState } from '../../common'; import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types'; -import { ToolbarPopover, LegendSettingsPopover, useDebouncedValue } from '../shared_components'; -import { PalettePicker } from '../shared_components'; +import { + ToolbarPopover, + LegendSettingsPopover, + useDebouncedValue, + PalettePicker, +} from '../shared_components'; import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; import { shouldShowValuesInLegend } from './render_helpers'; @@ -298,14 +302,12 @@ export function DimensionEditor( } ) { return ( - <> - { - props.setState({ ...props.state, palette: newPalette }); - }} - /> - + { + props.setState({ ...props.state, palette: newPalette }); + }} + /> ); } diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 4d883c3a27c5e..d2bb7cdbb4344 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -33,6 +33,7 @@ import type { NavigationPublicPluginStart } from '../../../../src/plugins/naviga import type { UrlForwardingSetup } from '../../../../src/plugins/url_forwarding/public'; import type { GlobalSearchPluginSetup } from '../../global_search/public'; import type { ChartsPluginSetup, ChartsPluginStart } from '../../../../src/plugins/charts/public'; +import type { EventAnnotationPluginSetup } from '../../../../src/plugins/event_annotation/public'; import type { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/public'; import type { EditorFrameService as EditorFrameServiceType } from './editor_frame_service'; @@ -104,6 +105,7 @@ export interface LensPluginSetupDependencies { embeddable?: EmbeddableSetup; visualizations: VisualizationsSetup; charts: ChartsPluginSetup; + eventAnnotation: EventAnnotationPluginSetup; globalSearch?: GlobalSearchPluginSetup; usageCollection?: UsageCollectionSetup; discover?: DiscoverSetup; @@ -120,6 +122,7 @@ export interface LensPluginStartDependencies { visualizations: VisualizationsStart; embeddable: EmbeddableStart; charts: ChartsPluginStart; + eventAnnotation: EventAnnotationPluginSetup; savedObjectsTagging?: SavedObjectTaggingPluginStart; presentationUtil: PresentationUtilPluginStart; dataViewFieldEditor: IndexPatternFieldEditorStart; @@ -235,6 +238,7 @@ export class LensPlugin { embeddable, visualizations, charts, + eventAnnotation, globalSearch, usageCollection, }: LensPluginSetupDependencies @@ -251,7 +255,8 @@ export class LensPlugin { charts, expressions, fieldFormats, - plugins.fieldFormats.deserialize + plugins.fieldFormats.deserialize, + eventAnnotation ); const visualizationMap = await this.editorFrameService!.loadVisualizations(); @@ -311,7 +316,8 @@ export class LensPlugin { charts, expressions, fieldFormats, - deps.fieldFormats.deserialize + deps.fieldFormats.deserialize, + eventAnnotation ), ensureDefaultDataView(), ]); @@ -368,7 +374,8 @@ export class LensPlugin { charts: ChartsPluginSetup, expressions: ExpressionsServiceSetup, fieldFormats: FieldFormatsSetup, - formatFactory: FormatFactory + formatFactory: FormatFactory, + eventAnnotation: EventAnnotationPluginSetup ) { const { DatatableVisualization, @@ -402,6 +409,7 @@ export class LensPlugin { charts, editorFrame: editorFrameSetupInterface, formatFactory, + eventAnnotation, }; this.indexpatternDatasource.setup(core, dependencies); this.xyVisualization.setup(core, dependencies); diff --git a/x-pack/plugins/lens/public/shared_components/dimension_section.scss b/x-pack/plugins/lens/public/shared_components/dimension_section.scss new file mode 100644 index 0000000000000..7781c91785d67 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/dimension_section.scss @@ -0,0 +1,24 @@ +.lnsDimensionEditorSection { + padding-top: $euiSize; + padding-bottom: $euiSize; +} + +.lnsDimensionEditorSection:first-child { + padding-top: 0; +} + +.lnsDimensionEditorSection:first-child .lnsDimensionEditorSection__border { + display: none; +} + +.lnsDimensionEditorSection__border { + position: relative; + &:before { + content: ''; + position: absolute; + top: -$euiSize; + right: -$euiSize; + left: -$euiSize; + border-top: 1px solid $euiColorLightShade; + } +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/shared_components/dimension_section.tsx b/x-pack/plugins/lens/public/shared_components/dimension_section.tsx new file mode 100644 index 0000000000000..d56e08db4b037 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/dimension_section.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiTitle } from '@elastic/eui'; +import React from 'react'; +import './dimension_section.scss'; + +export const DimensionEditorSection = ({ + children, + title, +}: { + title?: string; + children?: React.ReactNode | React.ReactNode[]; +}) => { + return ( +
+
+ {title && ( + +

{title}

+
+ )} + {children} +
+ ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index 6140e54b43dc7..b2428532a72c9 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -17,5 +17,6 @@ export { LegendActionPopover } from './legend_action_popover'; export { NameInput } from './name_input'; export { ValueLabelsSettings } from './value_labels_settings'; export { AxisTitleSettings } from './axis_title_settings'; +export { DimensionEditorSection } from './dimension_section'; export * from './static_header'; export * from './vis_label'; diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index 56ff89f506c85..959db8ca006fe 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -619,30 +619,39 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { return state; } - const activeDatasource = datasourceMap[state.activeDatasourceId]; const activeVisualization = visualizationMap[state.visualization.activeId]; - - const datasourceState = activeDatasource.insertLayer( - state.datasourceStates[state.activeDatasourceId].state, - layerId - ); - const visualizationState = activeVisualization.appendLayer!( state.visualization.state, layerId, layerType ); + const framePublicAPI = { + // any better idea to avoid `as`? + activeData: state.activeData + ? (current(state.activeData) as TableInspectorAdapter) + : undefined, + datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap), + }; + + const activeDatasource = datasourceMap[state.activeDatasourceId]; + const { noDatasource } = + activeVisualization + .getSupportedLayers(visualizationState, framePublicAPI) + .find(({ type }) => type === layerType) || {}; + + const datasourceState = + !noDatasource && activeDatasource + ? activeDatasource.insertLayer( + state.datasourceStates[state.activeDatasourceId].state, + layerId + ) + : state.datasourceStates[state.activeDatasourceId].state; + const { activeDatasourceState, activeVisualizationState } = addInitialValueIfAvailable({ datasourceState, visualizationState, - framePublicAPI: { - // any better idea to avoid `as`? - activeData: state.activeData - ? (current(state.activeData) as TableInspectorAdapter) - : undefined, - datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap), - }, + framePublicAPI, activeVisualization, activeDatasource, layerId, @@ -710,39 +719,49 @@ function addInitialValueIfAvailable({ framePublicAPI: FramePublicAPI; visualizationState: unknown; datasourceState: unknown; - activeDatasource: Datasource; + activeDatasource?: Datasource; activeVisualization: Visualization; layerId: string; layerType: string; columnId?: string; groupId?: string; }) { - const layerInfo = activeVisualization - .getSupportedLayers(visualizationState, framePublicAPI) - .find(({ type }) => type === layerType); + const { initialDimensions, noDatasource } = + activeVisualization + .getSupportedLayers(visualizationState, framePublicAPI) + .find(({ type }) => type === layerType) || {}; - if (layerInfo?.initialDimensions && activeDatasource?.initializeDimension) { + if (initialDimensions) { const info = groupId - ? layerInfo.initialDimensions.find(({ groupId: id }) => id === groupId) - : // pick the first available one if not passed - layerInfo.initialDimensions[0]; + ? initialDimensions.find(({ groupId: id }) => id === groupId) + : initialDimensions[0]; // pick the first available one if not passed if (info) { - return { - activeDatasourceState: activeDatasource.initializeDimension(datasourceState, layerId, { - ...info, - columnId: columnId || info.columnId, - }), - activeVisualizationState: activeVisualization.setDimension({ - groupId: info.groupId, - layerId, - columnId: columnId || info.columnId, - prevState: visualizationState, - frame: framePublicAPI, - }), - }; + const activeVisualizationState = activeVisualization.setDimension({ + groupId: info.groupId, + layerId, + columnId: columnId || info.columnId, + prevState: visualizationState, + frame: framePublicAPI, + }); + + if (!noDatasource && activeDatasource?.initializeDimension) { + return { + activeDatasourceState: activeDatasource.initializeDimension(datasourceState, layerId, { + ...info, + columnId: columnId || info.columnId, + }), + activeVisualizationState, + }; + } else { + return { + activeDatasourceState: datasourceState, + activeVisualizationState, + }; + } } } + return { activeDatasourceState: datasourceState, activeVisualizationState: visualizationState, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 9bea94bd723d3..cfa23320dc561 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -198,6 +198,12 @@ interface ChartSettings { }; } +export type GetDropProps = DatasourceDimensionDropProps & { + groupId: string; + dragging: DragContextState['dragging']; + prioritizedOperation?: string; +}; + /** * Interface for the datasource registry */ @@ -227,10 +233,8 @@ export interface Datasource { layerId: string, value: { columnId: string; - label: string; - dataType: string; - staticValue?: unknown; groupId: string; + staticValue?: unknown; } ) => T; @@ -251,11 +255,7 @@ export interface Datasource { props: DatasourceLayerPanelProps ) => ((cleanupElement: Element) => void) | void; getDropProps: ( - props: DatasourceDimensionDropProps & { - groupId: string; - dragging: DragContextState['dragging']; - prioritizedOperation?: string; - } + props: GetDropProps ) => { dropTypes: DropType[]; nextLabel?: string } | undefined; onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string }; /** @@ -585,6 +585,7 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & { supportStaticValue?: boolean; paramEditorCustomProps?: ParamEditorCustomProps; supportFieldFormat?: boolean; + labels?: { buttonAriaLabel: string; buttonLabel: string }; }; interface VisualizationDimensionChangeProps { @@ -786,14 +787,13 @@ export interface Visualization { type: LayerType; label: string; icon?: IconType; + noDatasource?: boolean; disabled?: boolean; toolTipContent?: string; initialDimensions?: Array<{ - groupId: string; columnId: string; - dataType: string; - label: string; - staticValue: unknown; + groupId: string; + staticValue?: unknown; }>; }>; getLayerType: (layerId: string, state?: T) => LayerType | undefined; @@ -858,7 +858,20 @@ export interface Visualization { domElement: Element, props: VisualizationDimensionEditorProps ) => ((cleanupElement: Element) => void) | void; - + /** + * Renders dimension trigger. Used only for noDatasource layers + */ + renderDimensionTrigger?: (props: { + columnId: string; + label: string; + hideTooltip?: boolean; + invalid?: boolean; + invalidMessage?: string; + }) => JSX.Element | null; + /** + * Creates map of columns ids and unique lables. Used only for noDatasource layers + */ + getUniqueLabels?: (state: T) => Record; /** * The frame will call this function on all visualizations at different times. The * main use cases where visualization suggestions are requested are: diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx index e1885fafab5e0..1770bac893b67 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx @@ -399,22 +399,16 @@ export const getGaugeVisualization = ({ { groupId: 'min', columnId: generateId(), - dataType: 'number', - label: 'minAccessor', staticValue: minValue, }, { groupId: 'max', columnId: generateId(), - dataType: 'number', - label: 'maxAccessor', staticValue: maxValue, }, { groupId: 'goal', columnId: generateId(), - dataType: 'number', - label: 'goalAccessor', staticValue: goalValue, }, ] diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index 504a553c5a631..fdde8eb6ad3f2 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -1,5 +1,218 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`xy_expression XYChart component annotations should render basic annotation 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": undefined, + "opacity": 1, + "stroke": "#f04e98", + "strokeWidth": 1, + }, + } + } +/> +`; + +exports[`xy_expression XYChart component annotations should render grouped annotations preserving the shared styles 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": Array [ + 9, + 3, + ], + "opacity": 1, + "stroke": "red", + "strokeWidth": 3, + }, + } + } +/> +`; + +exports[`xy_expression XYChart component annotations should render grouped annotations with default styles 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": undefined, + "opacity": 1, + "stroke": "#f04e98", + "strokeWidth": 1, + }, + } + } +/> +`; + +exports[`xy_expression XYChart component annotations should render simplified annotation when hide is true 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": undefined, + "opacity": 1, + "stroke": "#f04e98", + "strokeWidth": 1, + }, + } + } +/> +`; + exports[`xy_expression XYChart component it renders area 1`] = ` & { + formatFactory: FormatFactory; + paletteService: PaletteRegistry; + } +) => { + const { state, setState, layerId, accessor } = props; + const isHorizontal = isHorizontalChart(state.layers); + + const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({ + value: state, + onChange: setState, + }); + + const index = localState.layers.findIndex((l) => l.layerId === layerId); + const localLayer = localState.layers.find( + (l) => l.layerId === layerId + ) as XYAnnotationLayerConfig; + + const currentAnnotations = localLayer.annotations?.find((c) => c.id === accessor); + + const setAnnotations = useCallback( + (annotations: Partial | undefined) => { + if (annotations == null) { + return; + } + const newConfigs = [...(localLayer.annotations || [])]; + const existingIndex = newConfigs.findIndex((c) => c.id === accessor); + if (existingIndex !== -1) { + newConfigs[existingIndex] = { ...newConfigs[existingIndex], ...annotations }; + } else { + return; // that should never happen because annotations are created before annotations panel is opened + } + setLocalState(updateLayer(localState, { ...localLayer, annotations: newConfigs }, index)); + }, + [accessor, index, localState, localLayer, setLocalState] + ); + + return ( + <> + + { + if (date) { + setAnnotations({ + key: { + ...(currentAnnotations?.key || { type: 'point_in_time' }), + timestamp: date.toISOString(), + }, + }); + } + }} + label={i18n.translate('xpack.lens.xyChart.annotationDate', { + defaultMessage: 'Annotation date', + })} + /> + + + { + setAnnotations({ label: value }); + }} + /> + + + + setAnnotations({ isHidden: ev.target.checked })} + /> + + + ); +}; + +const ConfigPanelDatePicker = ({ + value, + label, + onChange, +}: { + value: moment.Moment; + label: string; + onChange: (val: moment.Moment | null) => void; +}) => { + return ( + + + + ); +}; + +const ConfigPanelHideSwitch = ({ + value, + onChange, +}: { + value: boolean; + onChange: (event: EuiSwitchEvent) => void; +}) => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss new file mode 100644 index 0000000000000..fc2b1204bb1d0 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss @@ -0,0 +1,37 @@ +.lnsXyDecorationRotatedWrapper { + display: inline-block; + overflow: hidden; + line-height: 1.5; + + .lnsXyDecorationRotatedWrapper__label { + display: inline-block; + white-space: nowrap; + transform: translate(0, 100%) rotate(-90deg); + transform-origin: 0 0; + + &::after { + content: ''; + float: left; + margin-top: 100%; + } + } +} + +.lnsXyAnnotationNumberIcon { + border-radius: $euiSize; + min-width: $euiSize; + height: $euiSize; + background-color: currentColor; +} + +.lnsXyAnnotationNumberIcon__text { + font-weight: 500; + font-size: 9px; + letter-spacing: -.5px; + line-height: 11px; +} + +.lnsXyAnnotationIcon_rotate90 { + transform: rotate(45deg); + transform-origin: center; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx new file mode 100644 index 0000000000000..c36488f29d238 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx @@ -0,0 +1,233 @@ +/* + * 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 './expression.scss'; +import React from 'react'; +import { snakeCase } from 'lodash'; +import { + AnnotationDomainType, + AnnotationTooltipFormatter, + LineAnnotation, + Position, +} from '@elastic/charts'; +import type { FieldFormat } from 'src/plugins/field_formats/common'; +import type { EventAnnotationArgs } from 'src/plugins/event_annotation/common'; +import moment from 'moment'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public'; +import type { AnnotationLayerArgs } from '../../../common/expressions'; +import { hasIcon } from '../xy_config_panel/shared/icon_select'; +import { + mapVerticalToHorizontalPlacement, + LINES_MARKER_SIZE, + MarkerBody, + Marker, + AnnotationIcon, +} from '../annotations_helpers'; + +const getRoundedTimestamp = (timestamp: number, firstTimestamp?: number, minInterval?: number) => { + if (!firstTimestamp || !minInterval) { + return timestamp; + } + return timestamp - ((timestamp - firstTimestamp) % minInterval); +}; + +export interface AnnotationsProps { + groupedAnnotations: CollectiveConfig[]; + formatter?: FieldFormat; + isHorizontal: boolean; + paddingMap: Partial>; + hide?: boolean; + minInterval?: number; + isBarChart?: boolean; +} + +interface CollectiveConfig extends EventAnnotationArgs { + roundedTimestamp: number; + axisMode: 'bottom'; + customTooltipDetails?: AnnotationTooltipFormatter | undefined; +} + +const groupVisibleConfigsByInterval = ( + layers: AnnotationLayerArgs[], + minInterval?: number, + firstTimestamp?: number +) => { + return layers + .flatMap(({ annotations }) => annotations.filter((a) => !a.isHidden)) + .reduce>((acc, current) => { + const roundedTimestamp = getRoundedTimestamp( + moment(current.time).valueOf(), + firstTimestamp, + minInterval + ); + return { + ...acc, + [roundedTimestamp]: acc[roundedTimestamp] ? [...acc[roundedTimestamp], current] : [current], + }; + }, {}); +}; + +const createCustomTooltipDetails = + ( + config: EventAnnotationArgs[], + formatter?: FieldFormat + ): AnnotationTooltipFormatter | undefined => + () => { + return ( +
+ {config.map(({ icon, label, time, color }) => ( +
+ + {hasIcon(icon) && ( + + + + )} + {label} + + {formatter?.convert(time) || String(time)} +
+ ))} +
+ ); + }; + +function getCommonProperty( + configArr: EventAnnotationArgs[], + propertyName: K, + fallbackValue: T +) { + const firstStyle = configArr[0][propertyName]; + if (configArr.every((config) => firstStyle === config[propertyName])) { + return firstStyle; + } + return fallbackValue; +} + +const getCommonStyles = (configArr: EventAnnotationArgs[]) => { + return { + color: getCommonProperty( + configArr, + 'color', + defaultAnnotationColor + ), + lineWidth: getCommonProperty(configArr, 'lineWidth', 1), + lineStyle: getCommonProperty(configArr, 'lineStyle', 'solid'), + textVisibility: getCommonProperty(configArr, 'textVisibility', false), + }; +}; + +export const getAnnotationsGroupedByInterval = ( + layers: AnnotationLayerArgs[], + minInterval?: number, + firstTimestamp?: number, + formatter?: FieldFormat +) => { + const visibleGroupedConfigs = groupVisibleConfigsByInterval(layers, minInterval, firstTimestamp); + let collectiveConfig: CollectiveConfig; + return Object.entries(visibleGroupedConfigs).map(([roundedTimestamp, configArr]) => { + collectiveConfig = { + ...configArr[0], + roundedTimestamp: Number(roundedTimestamp), + axisMode: 'bottom', + }; + if (configArr.length > 1) { + const commonStyles = getCommonStyles(configArr); + collectiveConfig = { + ...collectiveConfig, + ...commonStyles, + icon: String(configArr.length), + customTooltipDetails: createCustomTooltipDetails(configArr, formatter), + }; + } + return collectiveConfig; + }); +}; + +export const Annotations = ({ + groupedAnnotations, + formatter, + isHorizontal, + paddingMap, + hide, + minInterval, + isBarChart, +}: AnnotationsProps) => { + return ( + <> + {groupedAnnotations.map((annotation) => { + const markerPositionVertical = Position.Top; + const markerPosition = isHorizontal + ? mapVerticalToHorizontalPlacement(markerPositionVertical) + : markerPositionVertical; + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; + const id = snakeCase(annotation.label); + const { roundedTimestamp, time: exactTimestamp } = annotation; + const isGrouped = Boolean(annotation.customTooltipDetails); + const header = + formatter?.convert(isGrouped ? roundedTimestamp : exactTimestamp) || + moment(isGrouped ? roundedTimestamp : exactTimestamp).toISOString(); + const strokeWidth = annotation.lineWidth || 1; + return ( + + ) : undefined + } + markerBody={ + !hide ? ( + + ) : undefined + } + markerPosition={markerPosition} + dataValues={[ + { + dataValue: moment( + isBarChart && minInterval ? roundedTimestamp + minInterval / 2 : roundedTimestamp + ).valueOf(), + header, + details: annotation.label, + }, + ]} + customTooltipDetails={annotation.customTooltipDetails} + style={{ + line: { + strokeWidth, + stroke: annotation.color || defaultAnnotationColor, + dash: + annotation.lineStyle === 'dashed' + ? [strokeWidth * 3, strokeWidth] + : annotation.lineStyle === 'dotted' + ? [strokeWidth, strokeWidth] + : undefined, + opacity: 1, + }, + }} + /> + ); + })} + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts new file mode 100644 index 0000000000000..fbf13db7fa7a5 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts @@ -0,0 +1,210 @@ +/* + * 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 { FramePublicAPI } from '../../types'; +import { getStaticDate } from './helpers'; + +describe('annotations helpers', () => { + describe('getStaticDate', () => { + it('should return `now` value on when nothing is configured', () => { + jest.spyOn(Date, 'now').mockReturnValue(new Date('2022-04-08T11:01:58.135Z').valueOf()); + expect(getStaticDate([], undefined)).toBe('2022-04-08T11:01:58.135Z'); + }); + it('should return `now` value on when there is no active data', () => { + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + ], + undefined + ) + ).toBe('2022-04-08T11:01:58.135Z'); + }); + + it('should return timestamp value for single active data point', () => { + const activeData = { + layerId: { + type: 'datatable', + rows: [ + { + a: 1646002800000, + b: 1050, + }, + ], + columns: [ + { + id: 'a', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'b', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + }; + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + ], + activeData as FramePublicAPI['activeData'] + ) + ).toBe('2022-02-27T23:00:00.000Z'); + }); + + it('should correctly calculate middle value for active data', () => { + const activeData = { + layerId: { + type: 'datatable', + rows: [ + { + a: 1648206000000, + b: 19, + }, + { + a: 1648249200000, + b: 73, + }, + { + a: 1648292400000, + b: 69, + }, + { + a: 1648335600000, + b: 7, + }, + ], + columns: [ + { + id: 'a', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'b', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + }; + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + ], + activeData as FramePublicAPI['activeData'] + ) + ).toBe('2022-03-26T05:00:00.000Z'); + }); + + it('should calculate middle date point correctly for multiple layers', () => { + const activeData = { + layerId: { + type: 'datatable', + rows: [ + { + a: 1648206000000, + b: 19, + }, + { + a: 1648249200000, + b: 73, + }, + { + a: 1648292400000, + b: 69, + }, + { + a: 1648335600000, + b: 7, + }, + ], + columns: [ + { + id: 'a', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'b', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + layerId2: { + type: 'datatable', + rows: [ + { + d: 1548206000000, + c: 19, + }, + { + d: 1548249200000, + c: 73, + }, + ], + columns: [ + { + id: 'd', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'c', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + }; + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + { + layerId: 'layerId2', + accessors: ['c'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'd', + }, + ], + activeData as FramePublicAPI['activeData'] + ) + ).toBe('2020-08-24T12:06:40.000Z'); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx new file mode 100644 index 0000000000000..321090c94241a --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { layerTypes } from '../../../common'; +import type { + XYDataLayerConfig, + XYAnnotationLayerConfig, + XYLayerConfig, +} from '../../../common/expressions'; +import type { FramePublicAPI, Visualization } from '../../types'; +import { isHorizontalChart } from '../state_helpers'; +import type { XYState } from '../types'; +import { + checkScaleOperation, + getAnnotationsLayers, + getAxisName, + getDataLayers, + isAnnotationsLayer, +} from '../visualization_helpers'; +import { LensIconChartBarAnnotations } from '../../assets/chart_bar_annotations'; +import { generateId } from '../../id_generator'; +import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public'; +import { defaultAnnotationLabel } from './config_panel'; + +const MAX_DATE = 8640000000000000; +const MIN_DATE = -8640000000000000; + +export function getStaticDate( + dataLayers: XYDataLayerConfig[], + activeData: FramePublicAPI['activeData'] +) { + const fallbackValue = moment().toISOString(); + + const dataLayersId = dataLayers.map(({ layerId }) => layerId); + if ( + !activeData || + Object.entries(activeData) + .filter(([key]) => dataLayersId.includes(key)) + .every(([, { rows }]) => !rows || !rows.length) + ) { + return fallbackValue; + } + + const minDate = dataLayersId.reduce((acc, lId) => { + const xAccessor = dataLayers.find((dataLayer) => dataLayer.layerId === lId)?.xAccessor!; + const firstTimestamp = activeData[lId]?.rows?.[0]?.[xAccessor]; + return firstTimestamp && firstTimestamp < acc ? firstTimestamp : acc; + }, MAX_DATE); + + const maxDate = dataLayersId.reduce((acc, lId) => { + const xAccessor = dataLayers.find((dataLayer) => dataLayer.layerId === lId)?.xAccessor!; + const lastTimestamp = activeData[lId]?.rows?.[activeData?.[lId]?.rows?.length - 1]?.[xAccessor]; + return lastTimestamp && lastTimestamp > acc ? lastTimestamp : acc; + }, MIN_DATE); + const middleDate = (minDate + maxDate) / 2; + return moment(middleDate).toISOString(); +} + +export const getAnnotationsSupportedLayer = ( + state?: XYState, + frame?: Pick +) => { + const dataLayers = getDataLayers(state?.layers || []); + + const hasDateHistogram = Boolean( + dataLayers.length && + dataLayers.every( + (dataLayer) => + dataLayer.xAccessor && + checkScaleOperation('interval', 'date', frame?.datasourceLayers || {})(dataLayer) + ) + ); + const initialDimensions = + state && hasDateHistogram + ? [ + { + groupId: 'xAnnotations', + columnId: generateId(), + }, + ] + : undefined; + + return { + type: layerTypes.ANNOTATIONS, + label: i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabel', { + defaultMessage: 'Annotations', + }), + icon: LensIconChartBarAnnotations, + disabled: !hasDateHistogram, + toolTipContent: !hasDateHistogram + ? i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp', { + defaultMessage: 'Annotations require a time based chart to work. Add a date histogram.', + }) + : undefined, + initialDimensions, + noDatasource: true, + }; +}; + +export const setAnnotationsDimension: Visualization['setDimension'] = ({ + prevState, + layerId, + columnId, + previousColumn, + frame, +}) => { + const foundLayer = prevState.layers.find((l) => l.layerId === layerId); + if (!foundLayer || !isAnnotationsLayer(foundLayer)) { + return prevState; + } + const dataLayers = getDataLayers(prevState.layers); + const newLayer = { ...foundLayer } as XYAnnotationLayerConfig; + + const hasConfig = newLayer.annotations?.some(({ id }) => id === columnId); + const previousConfig = previousColumn + ? newLayer.annotations?.find(({ id }) => id === previousColumn) + : false; + if (!hasConfig) { + const newTimestamp = getStaticDate(dataLayers, frame?.activeData); + newLayer.annotations = [ + ...(newLayer.annotations || []), + { + label: defaultAnnotationLabel, + key: { + type: 'point_in_time', + timestamp: newTimestamp, + }, + icon: 'triangle', + ...previousConfig, + id: columnId, + }, + ]; + } + return { + ...prevState, + layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)), + }; +}; + +export const getAnnotationsAccessorColorConfig = (layer: XYAnnotationLayerConfig) => { + return layer.annotations.map((annotation) => { + return { + columnId: annotation.id, + triggerIcon: annotation.isHidden ? ('invisible' as const) : ('color' as const), + color: annotation?.color || defaultAnnotationColor, + }; + }); +}; + +export const getAnnotationsConfiguration = ({ + state, + frame, + layer, +}: { + state: XYState; + frame: FramePublicAPI; + layer: XYAnnotationLayerConfig; +}) => { + const dataLayers = getDataLayers(state.layers); + + const hasDateHistogram = Boolean( + dataLayers.length && + dataLayers.every( + (dataLayer) => + dataLayer.xAccessor && + checkScaleOperation('interval', 'date', frame?.datasourceLayers || {})(dataLayer) + ) + ); + + const groupLabel = getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }); + + const emptyButtonLabels = { + buttonAriaLabel: i18n.translate('xpack.lens.indexPattern.addColumnAriaLabelClick', { + defaultMessage: 'Add an annotation to {groupLabel}', + values: { groupLabel }, + }), + buttonLabel: i18n.translate('xpack.lens.configure.emptyConfigClick', { + defaultMessage: 'Add an annotation', + }), + }; + + return { + groups: [ + { + groupId: 'xAnnotations', + groupLabel, + accessors: getAnnotationsAccessorColorConfig(layer), + dataTestSubj: 'lnsXY_xAnnotationsPanel', + invalid: !hasDateHistogram, + invalidMessage: i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp', { + defaultMessage: 'Annotations require a time based chart to work. Add a date histogram.', + }), + required: false, + requiresPreviousColumnOnDuplicate: true, + supportsMoreColumns: true, + supportFieldFormat: false, + enableDimensionEditor: true, + filterOperations: () => false, + labels: emptyButtonLabels, + }, + ], + }; +}; + +export const getUniqueLabels = (layers: XYLayerConfig[]) => { + const annotationLayers = getAnnotationsLayers(layers); + const columnLabelMap = {} as Record; + const counts = {} as Record; + + const makeUnique = (label: string) => { + let uniqueLabel = label; + + while (counts[uniqueLabel] >= 0) { + const num = ++counts[uniqueLabel]; + uniqueLabel = i18n.translate('xpack.lens.uniqueLabel', { + defaultMessage: '{label} [{num}]', + values: { label, num }, + }); + } + + counts[uniqueLabel] = 0; + return uniqueLabel; + }; + + annotationLayers.forEach((layer) => { + if (!layer.annotations) { + return; + } + layer.annotations.forEach((l) => { + columnLabelMap[l.id] = makeUnique(l.label); + }); + }); + return columnLabelMap; +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx new file mode 100644 index 0000000000000..ddbdfc91f4a3e --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx @@ -0,0 +1,253 @@ +/* + * 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 './expression_reference_lines.scss'; +import React from 'react'; +import { EuiFlexGroup, EuiIcon, EuiIconProps, EuiText } from '@elastic/eui'; +import { Position } from '@elastic/charts'; +import classnames from 'classnames'; +import type { IconPosition, YAxisMode, YConfig } from '../../common/expressions'; +import { hasIcon } from './xy_config_panel/shared/icon_select'; +import { annotationsIconSet } from './annotations/config_panel/icon_set'; + +export const LINES_MARKER_SIZE = 20; + +export const computeChartMargins = ( + referenceLinePaddings: Partial>, + labelVisibility: Partial>, + titleVisibility: Partial>, + axesMap: Record<'left' | 'right', unknown>, + isHorizontal: boolean +) => { + const result: Partial> = {}; + if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; + result[placement] = referenceLinePaddings.bottom; + } + if ( + referenceLinePaddings.left && + (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; + result[placement] = referenceLinePaddings.left; + } + if ( + referenceLinePaddings.right && + (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; + result[placement] = referenceLinePaddings.right; + } + // there's no top axis, so just check if a margin has been computed + if (referenceLinePaddings.top) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; + result[placement] = referenceLinePaddings.top; + } + return result; +}; + +// Note: it does not take into consideration whether the reference line is in view or not + +export const getLinesCausedPaddings = ( + visualConfigs: Array< + Pick | undefined + >, + axesMap: Record<'left' | 'right', unknown> +) => { + // collect all paddings for the 4 axis: if any text is detected double it. + const paddings: Partial> = {}; + const icons: Partial> = {}; + visualConfigs?.forEach((config) => { + if (!config) { + return; + } + const { axisMode, icon, iconPosition, textVisibility } = config; + if (axisMode && (hasIcon(icon) || textVisibility)) { + const placement = getBaseIconPlacement(iconPosition, axesMap, axisMode); + paddings[placement] = Math.max( + paddings[placement] || 0, + LINES_MARKER_SIZE * (textVisibility ? 2 : 1) // double the padding size if there's text + ); + icons[placement] = (icons[placement] || 0) + (hasIcon(icon) ? 1 : 0); + } + }); + // post-process the padding based on the icon presence: + // if no icon is present for the placement, just reduce the padding + (Object.keys(paddings) as Position[]).forEach((placement) => { + if (!icons[placement]) { + paddings[placement] = LINES_MARKER_SIZE; + } + }); + return paddings; +}; + +export function mapVerticalToHorizontalPlacement(placement: Position) { + switch (placement) { + case Position.Top: + return Position.Right; + case Position.Bottom: + return Position.Left; + case Position.Left: + return Position.Bottom; + case Position.Right: + return Position.Top; + } +} + +// if there's just one axis, put it on the other one +// otherwise use the same axis +// this function assume the chart is vertical +export function getBaseIconPlacement( + iconPosition: IconPosition | undefined, + axesMap?: Record, + axisMode?: YAxisMode +) { + if (iconPosition === 'auto') { + if (axisMode === 'bottom') { + return Position.Top; + } + if (axesMap) { + if (axisMode === 'left') { + return axesMap.right ? Position.Left : Position.Right; + } + return axesMap.left ? Position.Right : Position.Left; + } + } + + if (iconPosition === 'left') { + return Position.Left; + } + if (iconPosition === 'right') { + return Position.Right; + } + if (iconPosition === 'below') { + return Position.Bottom; + } + return Position.Top; +} + +export function MarkerBody({ + label, + isHorizontal, +}: { + label: string | undefined; + isHorizontal: boolean; +}) { + if (!label) { + return null; + } + if (isHorizontal) { + return ( +
+ {label} +
+ ); + } + return ( +
+
+ {label} +
+
+ ); +} + +const isNumericalString = (value: string) => !isNaN(Number(value)); + +function NumberIcon({ number }: { number: number }) { + return ( + + + {number < 10 ? number : `9+`} + + + ); +} + +interface MarkerConfig { + axisMode?: YAxisMode; + icon?: string; + textVisibility?: boolean; + iconPosition?: IconPosition; +} + +export const AnnotationIcon = ({ + type, + rotateClassName = '', + isHorizontal, + renderedInChart, + ...rest +}: { + type: string; + rotateClassName?: string; + isHorizontal?: boolean; + renderedInChart?: boolean; +} & EuiIconProps) => { + if (isNumericalString(type)) { + return ; + } + const iconConfig = annotationsIconSet.find((i) => i.value === type); + if (!iconConfig) { + return null; + } + return ( + + ); +}; + +export function Marker({ + config, + isHorizontal, + hasReducedPadding, + label, + rotateClassName, +}: { + config: MarkerConfig; + isHorizontal: boolean; + hasReducedPadding: boolean; + label?: string; + rotateClassName?: string; +}) { + if (hasIcon(config.icon)) { + return ( + + ); + } + + // if there's some text, check whether to show it as marker, or just show some padding for the icon + if (config.textVisibility) { + if (hasReducedPadding) { + return ; + } + return ; + } + return null; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index 82c1106e72a08..f8d5805279a2e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -13,7 +13,9 @@ import type { AccessorConfig, FramePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; import { FormatFactory, LayerType } from '../../common'; import type { XYLayerConfig } from '../../common/expressions'; -import { isDataLayer, isReferenceLayer } from './visualization_helpers'; +import { isReferenceLayer, isAnnotationsLayer } from './visualization_helpers'; +import { getAnnotationsAccessorColorConfig } from './annotations/helpers'; +import { getReferenceLineAccessorColorConfig } from './reference_line_helpers'; const isPrimitive = (value: unknown): boolean => value != null && typeof value !== 'object'; @@ -42,15 +44,13 @@ export function getColorAssignments( ): ColorAssignments { const layersPerPalette: Record = {}; - layers - .filter((layer) => isDataLayer(layer)) - .forEach((layer) => { - const palette = layer.palette?.name || 'default'; - if (!layersPerPalette[palette]) { - layersPerPalette[palette] = []; - } - layersPerPalette[palette].push(layer); - }); + layers.forEach((layer) => { + const palette = layer.palette?.name || 'default'; + if (!layersPerPalette[palette]) { + layersPerPalette[palette] = []; + } + layersPerPalette[palette].push(layer); + }); return mapValues(layersPerPalette, (paletteLayers) => { const seriesPerLayer = paletteLayers.map((layer, layerIndex) => { @@ -102,17 +102,6 @@ export function getColorAssignments( }); } -const getReferenceLineAccessorColorConfig = (layer: XYLayerConfig) => { - return layer.accessors.map((accessor) => { - const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor); - return { - columnId: accessor, - triggerIcon: 'color' as const, - color: currentYConfig?.color || defaultReferenceLineColor, - }; - }); -}; - export function getAccessorColorConfig( colorAssignments: ColorAssignments, frame: Pick, @@ -122,7 +111,9 @@ export function getAccessorColorConfig( if (isReferenceLayer(layer)) { return getReferenceLineAccessorColorConfig(layer); } - + if (isAnnotationsLayer(layer)) { + return getAnnotationsAccessorColorConfig(layer); + } const layerContainsSplits = Boolean(layer.splitAccessor); const currentPalette: PaletteOutput = layer.palette || { type: 'palette', name: 'default' }; const totalSeriesCount = colorAssignments[currentPalette.name]?.totalSeriesCount; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 654a0f1b94a14..03a180cc20a08 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -20,12 +20,13 @@ import { HorizontalAlignment, VerticalAlignment, LayoutDirection, + LineAnnotation, } from '@elastic/charts'; import { PaletteOutput } from 'src/plugins/charts/public'; import { calculateMinInterval, XYChart, XYChartRenderProps } from './expression'; import type { LensMultiTable } from '../../common'; import { layerTypes } from '../../common'; -import { xyChart } from '../../common/expressions'; +import { AnnotationLayerArgs, xyChart } from '../../common/expressions'; import { dataLayerConfig, legendConfig, @@ -41,12 +42,14 @@ import { } from '../../common/expressions'; import { Datatable, DatatableRow } from '../../../../../src/plugins/expressions/public'; import React from 'react'; -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import { XyEndzones } from './x_domain'; +import { eventAnnotationServiceMock } from '../../../../../src/plugins/event_annotation/public/mocks'; +import { EventAnnotationOutput } from 'src/plugins/event_annotation/common'; const onClickValue = jest.fn(); const onSelectRange = jest.fn(); @@ -536,6 +539,7 @@ describe('xy_expression', () => { onSelectRange, syncColors: false, useLegacyTimeAxis: false, + eventAnnotationService: eventAnnotationServiceMock, }; }); @@ -546,7 +550,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -613,7 +617,9 @@ describe('xy_expression', () => { }} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'line', xScaleType: 'time' }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'time' }, + ], }} minInterval={undefined} /> @@ -802,7 +808,7 @@ describe('xy_expression', () => { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'time', isHistogram: true, @@ -878,7 +884,7 @@ describe('xy_expression', () => { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', xScaleType: 'time', isHistogram: true, @@ -975,7 +981,7 @@ describe('xy_expression', () => { }, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'area', }, ], @@ -1006,7 +1012,7 @@ describe('xy_expression', () => { }, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', }, ], @@ -1083,7 +1089,9 @@ describe('xy_expression', () => { }} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'line', xScaleType: 'linear' }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'linear' }, + ], }} /> ); @@ -1102,7 +1110,12 @@ describe('xy_expression', () => { args={{ ...args, layers: [ - { ...args.layers[0], seriesType: 'line', xScaleType: 'linear', isHistogram: true }, + { + ...(args.layers[0] as DataLayerArgs), + seriesType: 'line', + xScaleType: 'linear', + isHistogram: true, + }, ], }} /> @@ -1150,7 +1163,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1165,7 +1178,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1180,7 +1193,10 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1678,7 +1694,10 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1693,7 +1712,10 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1710,7 +1732,9 @@ describe('xy_expression', () => { data={data} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'bar_horizontal_stacked' }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'bar_horizontal_stacked' }, + ], }} /> ); @@ -1732,7 +1756,7 @@ describe('xy_expression', () => { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), xAccessor: undefined, splitAccessor: 'e', seriesType: 'bar_stacked', @@ -1762,7 +1786,7 @@ describe('xy_expression', () => { accessors: ['b'], seriesType: 'bar', isHistogram: true, - }; + } as DataLayerArgs; delete firstLayer.splitAccessor; const component = shallow( @@ -1772,7 +1796,11 @@ describe('xy_expression', () => { test('it does not apply histogram mode to more than one bar series for unstacked bar chart', () => { const { data, args } = sampleArgs(); - const firstLayer: DataLayerArgs = { ...args.layers[0], seriesType: 'bar', isHistogram: true }; + const firstLayer: DataLayerArgs = { + ...args.layers[0], + seriesType: 'bar', + isHistogram: true, + } as DataLayerArgs; delete firstLayer.splitAccessor; const component = shallow( @@ -1787,13 +1815,13 @@ describe('xy_expression', () => { ...args.layers[0], seriesType: 'line', isHistogram: true, - }; + } as DataLayerArgs; delete firstLayer.splitAccessor; const secondLayer: DataLayerArgs = { ...args.layers[0], seriesType: 'line', isHistogram: true, - }; + } as DataLayerArgs; delete secondLayer.splitAccessor; const component = shallow( { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'bar_stacked', isHistogram: true, }, @@ -1836,7 +1864,9 @@ describe('xy_expression', () => { data={data} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'bar', isHistogram: true }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', isHistogram: true }, + ], }} /> ); @@ -2232,7 +2262,10 @@ describe('xy_expression', () => { ); expect(component.find(LineSeries).at(0).prop('xScaleType')).toEqual(ScaleType.Ordinal); @@ -2246,7 +2279,7 @@ describe('xy_expression', () => { ); expect(component.find(LineSeries).at(0).prop('yScaleType')).toEqual(ScaleType.Sqrt); @@ -2268,7 +2301,7 @@ describe('xy_expression', () => { ); expect(getFormatSpy).toHaveBeenCalledWith({ @@ -2678,7 +2711,9 @@ describe('xy_expression', () => { data={{ ...data }} args={{ ...args, - layers: [{ ...args.layers[0], accessors: ['a'], splitAccessor: undefined }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), accessors: ['a'], splitAccessor: undefined }, + ], legend: { ...args.legend, isVisible: true, showSingleSeries: true }, }} /> @@ -2696,7 +2731,13 @@ describe('xy_expression', () => { data={{ ...data }} args={{ ...args, - layers: [{ ...args.layers[0], accessors: ['a'], splitAccessor: undefined }], + layers: [ + { + ...(args.layers[0] as DataLayerArgs), + accessors: ['a'], + splitAccessor: undefined, + }, + ], legend: { ...args.legend, isVisible: true, isInside: true }, }} /> @@ -2782,7 +2823,7 @@ describe('xy_expression', () => { test('it should apply None fitting function if not specified', () => { const { data, args } = sampleArgs(); - args.layers[0].accessors = ['a']; + (args.layers[0] as DataLayerArgs).accessors = ['a']; const component = shallow( @@ -2920,6 +2961,139 @@ describe('xy_expression', () => { }, ]); }); + + describe('annotations', () => { + const sampleStyledAnnotation: EventAnnotationOutput = { + time: '2022-03-18T08:25:00.000Z', + label: 'Event 1', + icon: 'triangle', + type: 'manual_event_annotation', + color: 'red', + lineStyle: 'dashed', + lineWidth: 3, + }; + const sampleAnnotationLayers: AnnotationLayerArgs[] = [ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + { + time: '2022-03-18T08:25:17.140Z', + label: 'Annotation', + type: 'manual_event_annotation', + }, + ], + }, + ]; + function sampleArgsWithAnnotation(annotationLayers = sampleAnnotationLayers) { + const { args } = sampleArgs(); + return { + data: dateHistogramData, + args: { + ...args, + layers: [dateHistogramLayer, ...annotationLayers], + } as XYArgs, + }; + } + test('should render basic annotation', () => { + const { data, args } = sampleArgsWithAnnotation(); + const component = mount(); + expect(component.find('LineAnnotation')).toMatchSnapshot(); + }); + test('should render simplified annotation when hide is true', () => { + const { data, args } = sampleArgsWithAnnotation(); + args.layers[0].hide = true; + const component = mount(); + expect(component.find('LineAnnotation')).toMatchSnapshot(); + }); + + test('should render grouped annotations preserving the shared styles', () => { + const { data, args } = sampleArgsWithAnnotation([ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + sampleStyledAnnotation, + { ...sampleStyledAnnotation, time: '2022-03-18T08:25:00.020Z', label: 'Event 2' }, + { + ...sampleStyledAnnotation, + time: '2022-03-18T08:25:00.001Z', + label: 'Event 3', + }, + ], + }, + ]); + const component = mount(); + const groupedAnnotation = component.find(LineAnnotation); + + expect(groupedAnnotation.length).toEqual(1); + // styles are passed because they are shared, dataValues & header is rounded to the interval + expect(groupedAnnotation).toMatchSnapshot(); + // renders numeric icon for grouped annotations + const marker = mount(
{groupedAnnotation.prop('marker')}
); + const numberIcon = marker.find('NumberIcon'); + expect(numberIcon.length).toEqual(1); + expect(numberIcon.text()).toEqual('3'); + + // checking tooltip + const renderLinks = mount(
{groupedAnnotation.prop('customTooltipDetails')!()}
); + expect(renderLinks.text()).toEqual( + ' Event 1 2022-03-18T08:25:00.000Z Event 2 2022-03-18T08:25:00.020Z Event 3 2022-03-18T08:25:00.001Z' + ); + }); + test('should render grouped annotations with default styles', () => { + const { data, args } = sampleArgsWithAnnotation([ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [sampleStyledAnnotation], + }, + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + { + ...sampleStyledAnnotation, + icon: 'square', + color: 'blue', + lineStyle: 'dotted', + lineWidth: 10, + time: '2022-03-18T08:25:00.001Z', + label: 'Event 2', + }, + ], + }, + ]); + const component = mount(); + const groupedAnnotation = component.find(LineAnnotation); + + expect(groupedAnnotation.length).toEqual(1); + // styles are default because they are different for both annotations + expect(groupedAnnotation).toMatchSnapshot(); + }); + test('should not render hidden annotations', () => { + const { data, args } = sampleArgsWithAnnotation([ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + sampleStyledAnnotation, + { ...sampleStyledAnnotation, time: '2022-03-18T08:30:00.020Z', label: 'Event 2' }, + { + ...sampleStyledAnnotation, + time: '2022-03-18T08:35:00.001Z', + label: 'Event 3', + isHidden: true, + }, + ], + }, + ]); + const component = mount(); + const annotations = component.find(LineAnnotation); + + expect(annotations.length).toEqual(2); + }); + }); }); describe('calculateMinInterval', () => { @@ -2927,7 +3101,7 @@ describe('xy_expression', () => { beforeEach(() => { xyProps = sampleArgs(); - xyProps.args.layers[0].xScaleType = 'time'; + (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'time'; }); it('should use first valid layer and determine interval', async () => { xyProps.data.tables.first.columns[2].meta.source = 'esaggs'; @@ -2942,7 +3116,7 @@ describe('xy_expression', () => { }); it('should return interval of number histogram if available on first x axis columns', async () => { - xyProps.args.layers[0].xScaleType = 'linear'; + (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'linear'; xyProps.data.tables.first.columns[2].meta = { source: 'esaggs', type: 'number', @@ -2984,7 +3158,7 @@ describe('xy_expression', () => { }); it('should return undefined if x axis is not a date', async () => { - xyProps.args.layers[0].xScaleType = 'ordinal'; + (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'ordinal'; xyProps.data.tables.first.columns.splice(2, 1); const result = await calculateMinInterval(xyProps); expect(result).toEqual(undefined); diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 72a3f5f4f6976..105b9d24bb09b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -50,11 +50,17 @@ import { i18n } from '@kbn/i18n'; import { RenderMode } from 'src/plugins/expressions'; import { ThemeServiceStart } from 'kibana/public'; import { FieldFormat } from 'src/plugins/field_formats/common'; +import { EventAnnotationServiceType } from '../../../../../src/plugins/event_annotation/public'; import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import type { ILensInterpreterRenderHandlers, LensFilterEvent, LensBrushEvent } from '../types'; import type { LensMultiTable, FormatFactory } from '../../common'; -import type { DataLayerArgs, SeriesType, XYChartProps } from '../../common/expressions'; +import type { + DataLayerArgs, + SeriesType, + XYChartProps, + XYLayerArgs, +} from '../../common/expressions'; import { visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart, getSeriesColor } from './state_helpers'; @@ -72,13 +78,17 @@ import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axe import { getColorAssignments } from './color_assignment'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './get_legend_action'; -import { - computeChartMargins, - getReferenceLineRequiredPaddings, - ReferenceLineAnnotations, -} from './expression_reference_lines'; +import { ReferenceLineAnnotations } from './expression_reference_lines'; + +import { computeChartMargins, getLinesCausedPaddings } from './annotations_helpers'; + +import { Annotations, getAnnotationsGroupedByInterval } from './annotations/expression'; import { computeOverallDataDomain } from './reference_line_helpers'; -import { getReferenceLayers, isDataLayer } from './visualization_helpers'; +import { + getReferenceLayers, + getDataLayersArgs, + getAnnotationsLayersArgs, +} from './visualization_helpers'; declare global { interface Window { @@ -104,6 +114,7 @@ export type XYChartRenderProps = XYChartProps & { onSelectRange: (data: LensBrushEvent['data']) => void; renderMode: RenderMode; syncColors: boolean; + eventAnnotationService: EventAnnotationServiceType; }; export function calculateMinInterval({ args: { layers }, data }: XYChartProps) { @@ -140,6 +151,7 @@ export const getXyChartRenderer = (dependencies: { timeZone: string; useLegacyTimeAxis: boolean; kibanaTheme: ThemeServiceStart; + eventAnnotationService: EventAnnotationServiceType; }): ExpressionRenderDefinition => ({ name: 'lens_xy_chart_renderer', displayName: 'XY chart', @@ -170,6 +182,7 @@ export const getXyChartRenderer = (dependencies: { chartsActiveCursorService={dependencies.chartsActiveCursorService} chartsThemeService={dependencies.chartsThemeService} paletteService={dependencies.paletteService} + eventAnnotationService={dependencies.eventAnnotationService} timeZone={dependencies.timeZone} useLegacyTimeAxis={dependencies.useLegacyTimeAxis} minInterval={calculateMinInterval(config)} @@ -265,7 +278,9 @@ export function XYChart({ }); if (filteredLayers.length === 0) { - const icon: IconType = getIconForSeriesType(layers?.[0]?.seriesType || 'bar'); + const icon: IconType = getIconForSeriesType( + getDataLayersArgs(layers)?.[0]?.seriesType || 'bar' + ); return ; } @@ -279,8 +294,8 @@ export function XYChart({ // This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers const safeXAccessorLabelRenderer = (value: unknown): string => xAxisColumn && layersAlreadyFormatted[xAxisColumn.id] - ? (value as string) - : xAxisFormatter.convert(value); + ? String(value) + : String(xAxisFormatter.convert(value)); const chartHasMoreThanOneSeries = filteredLayers.length > 1 || @@ -353,7 +368,23 @@ export function XYChart({ }; const referenceLineLayers = getReferenceLayers(layers); - const referenceLinePaddings = getReferenceLineRequiredPaddings(referenceLineLayers, yAxesMap); + const annotationsLayers = getAnnotationsLayersArgs(layers); + const firstTable = data.tables[filteredLayers[0].layerId]; + + const xColumnId = firstTable.columns.find((col) => col.id === filteredLayers[0].xAccessor)?.id; + + const groupedAnnotations = getAnnotationsGroupedByInterval( + annotationsLayers, + minInterval, + xColumnId ? firstTable.rows[0]?.[xColumnId] : undefined, + xAxisFormatter + ); + const visualConfigs = [ + ...referenceLineLayers.flatMap(({ yConfig }) => yConfig), + ...groupedAnnotations, + ].filter(Boolean); + + const linesPaddings = getLinesCausedPaddings(visualConfigs, yAxesMap); const getYAxesStyle = (groupId: 'left' | 'right') => { const tickVisible = @@ -369,9 +400,9 @@ export function XYChart({ ? args.labelsOrientation?.yRight || 0 : args.labelsOrientation?.yLeft || 0, padding: - referenceLinePaddings[groupId] != null + linesPaddings[groupId] != null ? { - inner: referenceLinePaddings[groupId], + inner: linesPaddings[groupId], } : undefined, }, @@ -382,9 +413,9 @@ export function XYChart({ : axisTitlesVisibilitySettings?.yLeft, // if labels are not visible add the padding to the title padding: - !tickVisible && referenceLinePaddings[groupId] != null + !tickVisible && linesPaddings[groupId] != null ? { - inner: referenceLinePaddings[groupId], + inner: linesPaddings[groupId], } : undefined, }, @@ -458,7 +489,7 @@ export function XYChart({ const valueLabelsStyling = shouldShowValueLabels && valueLabels !== 'hide' && getValueLabelsStyling(shouldRotate); - const colorAssignments = getColorAssignments(args.layers, data, formatFactory); + const colorAssignments = getColorAssignments(getDataLayersArgs(args.layers), data, formatFactory); const clickHandler: ElementClickListener = ([[geometry, series]]) => { // for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue @@ -591,16 +622,13 @@ export function XYChart({ tickLabel: { visible: tickLabelsVisibilitySettings?.x, rotation: labelsOrientation?.x, - padding: - referenceLinePaddings.bottom != null - ? { inner: referenceLinePaddings.bottom } - : undefined, + padding: linesPaddings.bottom != null ? { inner: linesPaddings.bottom } : undefined, }, axisTitle: { visible: axisTitlesVisibilitySettings.x, padding: - !tickLabelsVisibilitySettings?.x && referenceLinePaddings.bottom != null - ? { inner: referenceLinePaddings.bottom } + !tickLabelsVisibilitySettings?.x && linesPaddings.bottom != null + ? { inner: linesPaddings.bottom } : undefined, }, }; @@ -633,7 +661,7 @@ export function XYChart({ chartMargins: { ...chartTheme.chartPaddings, ...computeChartMargins( - referenceLinePaddings, + linesPaddings, tickLabelsVisibilitySettings, axisTitlesVisibilitySettings, yAxesMap, @@ -1005,29 +1033,37 @@ export function XYChart({ right: Boolean(yAxesMap.right), }} isHorizontal={shouldRotate} - paddingMap={referenceLinePaddings} + paddingMap={linesPaddings} + /> + ) : null} + {groupedAnnotations.length ? ( + 0} + minInterval={minInterval} /> ) : null}
); } -function getFilteredLayers(layers: DataLayerArgs[], data: LensMultiTable) { - return layers.filter((layer) => { +function getFilteredLayers(layers: XYLayerArgs[], data: LensMultiTable) { + return getDataLayersArgs(layers).filter((layer) => { const { layerId, xAccessor, accessors, splitAccessor } = layer; - return ( - isDataLayer(layer) && - !( - !accessors.length || - !data.tables[layerId] || - data.tables[layerId].rows.length === 0 || - (xAccessor && - data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || - // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty - (!xAccessor && - splitAccessor && - data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) - ) + return !( + !accessors.length || + !data.tables[layerId] || + data.tables[layerId].rows.length === 0 || + (xAccessor && + data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || + // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty + (!xAccessor && + splitAccessor && + data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) ); }); } diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx index 2d22f6a6ed76e..7817db573e419 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx @@ -8,183 +8,19 @@ import './expression_reference_lines.scss'; import React from 'react'; import { groupBy } from 'lodash'; -import { EuiIcon } from '@elastic/eui'; import { RectAnnotation, AnnotationDomainType, LineAnnotation, Position } from '@elastic/charts'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { FieldFormat } from 'src/plugins/field_formats/common'; -import { euiLightVars } from '@kbn/ui-theme'; -import type { IconPosition, ReferenceLineLayerArgs, YAxisMode } from '../../common/expressions'; +import type { ReferenceLineLayerArgs } from '../../common/expressions'; import type { LensMultiTable } from '../../common/types'; -import { hasIcon } from './xy_config_panel/shared/icon_select'; - -export const REFERENCE_LINE_MARKER_SIZE = 20; - -export const computeChartMargins = ( - referenceLinePaddings: Partial>, - labelVisibility: Partial>, - titleVisibility: Partial>, - axesMap: Record<'left' | 'right', unknown>, - isHorizontal: boolean -) => { - const result: Partial> = {}; - if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; - result[placement] = referenceLinePaddings.bottom; - } - if ( - referenceLinePaddings.left && - (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; - result[placement] = referenceLinePaddings.left; - } - if ( - referenceLinePaddings.right && - (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; - result[placement] = referenceLinePaddings.right; - } - // there's no top axis, so just check if a margin has been computed - if (referenceLinePaddings.top) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; - result[placement] = referenceLinePaddings.top; - } - return result; -}; - -// Note: it does not take into consideration whether the reference line is in view or not -export const getReferenceLineRequiredPaddings = ( - referenceLineLayers: ReferenceLineLayerArgs[], - axesMap: Record<'left' | 'right', unknown> -) => { - // collect all paddings for the 4 axis: if any text is detected double it. - const paddings: Partial> = {}; - const icons: Partial> = {}; - referenceLineLayers.forEach((layer) => { - layer.yConfig?.forEach(({ axisMode, icon, iconPosition, textVisibility }) => { - if (axisMode && (hasIcon(icon) || textVisibility)) { - const placement = getBaseIconPlacement(iconPosition, axisMode, axesMap); - paddings[placement] = Math.max( - paddings[placement] || 0, - REFERENCE_LINE_MARKER_SIZE * (textVisibility ? 2 : 1) // double the padding size if there's text - ); - icons[placement] = (icons[placement] || 0) + (hasIcon(icon) ? 1 : 0); - } - }); - }); - // post-process the padding based on the icon presence: - // if no icon is present for the placement, just reduce the padding - (Object.keys(paddings) as Position[]).forEach((placement) => { - if (!icons[placement]) { - paddings[placement] = REFERENCE_LINE_MARKER_SIZE; - } - }); - - return paddings; -}; - -function mapVerticalToHorizontalPlacement(placement: Position) { - switch (placement) { - case Position.Top: - return Position.Right; - case Position.Bottom: - return Position.Left; - case Position.Left: - return Position.Bottom; - case Position.Right: - return Position.Top; - } -} - -// if there's just one axis, put it on the other one -// otherwise use the same axis -// this function assume the chart is vertical -function getBaseIconPlacement( - iconPosition: IconPosition | undefined, - axisMode: YAxisMode | undefined, - axesMap: Record -) { - if (iconPosition === 'auto') { - if (axisMode === 'bottom') { - return Position.Top; - } - if (axisMode === 'left') { - return axesMap.right ? Position.Left : Position.Right; - } - return axesMap.left ? Position.Right : Position.Left; - } - - if (iconPosition === 'left') { - return Position.Left; - } - if (iconPosition === 'right') { - return Position.Right; - } - if (iconPosition === 'below') { - return Position.Bottom; - } - return Position.Top; -} - -function getMarkerBody(label: string | undefined, isHorizontal: boolean) { - if (!label) { - return; - } - if (isHorizontal) { - return ( -
- {label} -
- ); - } - return ( -
-
- {label} -
-
- ); -} - -interface MarkerConfig { - axisMode?: YAxisMode; - icon?: string; - textVisibility?: boolean; -} - -function getMarkerToShow( - markerConfig: MarkerConfig, - label: string | undefined, - isHorizontal: boolean, - hasReducedPadding: boolean -) { - // show an icon if present - if (hasIcon(markerConfig.icon)) { - return ; - } - // if there's some text, check whether to show it as marker, or just show some padding for the icon - if (markerConfig.textVisibility) { - if (hasReducedPadding) { - return getMarkerBody( - label, - (!isHorizontal && markerConfig.axisMode === 'bottom') || - (isHorizontal && markerConfig.axisMode !== 'bottom') - ); - } - return ; - } -} +import { defaultReferenceLineColor } from './color_assignment'; +import { + MarkerBody, + Marker, + LINES_MARKER_SIZE, + mapVerticalToHorizontalPlacement, + getBaseIconPlacement, +} from './annotations_helpers'; export interface ReferenceLineAnnotationsProps { layers: ReferenceLineLayerArgs[]; @@ -241,32 +77,40 @@ export const ReferenceLineAnnotations = ({ const formatter = formatters[groupId || 'bottom']; - const defaultColor = euiLightVars.euiColorDarkShade; - // get the position for vertical chart const markerPositionVertical = getBaseIconPlacement( yConfig.iconPosition, - yConfig.axisMode, - axesMap + axesMap, + yConfig.axisMode ); // the padding map is built for vertical chart - const hasReducedPadding = - paddingMap[markerPositionVertical] === REFERENCE_LINE_MARKER_SIZE; + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; const props = { groupId, - marker: getMarkerToShow( - yConfig, - columnToLabelMap[yConfig.forAccessor], - isHorizontal, - hasReducedPadding + marker: ( + ), - markerBody: getMarkerBody( - yConfig.textVisibility && !hasReducedPadding - ? columnToLabelMap[yConfig.forAccessor] - : undefined, - (!isHorizontal && yConfig.axisMode === 'bottom') || - (isHorizontal && yConfig.axisMode !== 'bottom') + markerBody: ( + ), // rotate the position if required markerPosition: isHorizontal @@ -284,7 +128,7 @@ export const ReferenceLineAnnotations = ({ const sharedStyle = { strokeWidth: yConfig.lineWidth || 1, - stroke: yConfig.color || defaultColor, + stroke: yConfig.color || defaultReferenceLineColor, dash: dashStyle, }; @@ -355,7 +199,7 @@ export const ReferenceLineAnnotations = ({ })} style={{ ...sharedStyle, - fill: yConfig.color || defaultColor, + fill: yConfig.color || defaultReferenceLineColor, opacity: 0.1, }} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index 9697ba149e16e..cfeb1387f689c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -6,6 +6,7 @@ */ import type { CoreSetup } from 'kibana/public'; +import { EventAnnotationPluginSetup } from '../../../../../src/plugins/event_annotation/public'; import type { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import type { EditorFrameSetup } from '../types'; import type { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; @@ -19,6 +20,7 @@ export interface XyVisualizationPluginSetupPlugins { formatFactory: FormatFactory; editorFrame: EditorFrameSetup; charts: ChartsPluginSetup; + eventAnnotation: EventAnnotationPluginSetup; } export class XyVisualization { @@ -28,8 +30,9 @@ export class XyVisualization { ) { editorFrame.registerVisualization(async () => { const { getXyChartRenderer, getXyVisualization } = await import('../async_services'); - const [, { charts, fieldFormats }] = await core.getStartServices(); + const [, { charts, fieldFormats, eventAnnotation }] = await core.getStartServices(); const palettes = await charts.palettes.getPalettes(); + const eventAnnotationService = await eventAnnotation.getService(); const useLegacyTimeAxis = core.uiSettings.get(LEGACY_TIME_AXIS); expressions.registerRenderer( getXyChartRenderer({ @@ -37,6 +40,7 @@ export class XyVisualization { chartsThemeService: charts.theme, chartsActiveCursorService: charts.activeCursor, paletteService: palettes, + eventAnnotationService, timeZone: getTimeZone(core.uiSettings), useLegacyTimeAxis, kibanaTheme: core.theme, @@ -44,6 +48,7 @@ export class XyVisualization { ); return getXyVisualization({ paletteService: palettes, + eventAnnotationService, fieldFormats, useLegacyTimeAxis, kibanaTheme: core.theme, diff --git a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx index ac50a81da5423..8b6a96ce24d44 100644 --- a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx @@ -14,7 +14,7 @@ import type { YConfig, } from '../../common/expressions'; import { Datatable } from '../../../../../src/plugins/expressions/public'; -import type { AccessorConfig, DatasourcePublicAPI, FramePublicAPI, Visualization } from '../types'; +import type { DatasourcePublicAPI, FramePublicAPI, Visualization } from '../types'; import { groupAxesByType } from './axes_configuration'; import { isHorizontalChart, isPercentageSeries, isStackedChart } from './state_helpers'; import type { XYState } from './types'; @@ -27,6 +27,7 @@ import { } from './visualization_helpers'; import { generateId } from '../id_generator'; import { LensIconChartBarReferenceLine } from '../assets/chart_bar_reference_line'; +import { defaultReferenceLineColor } from './color_assignment'; export interface ReferenceLineBase { label: 'x' | 'yRight' | 'yLeft'; @@ -360,18 +361,29 @@ export const setReferenceDimension: Visualization['setDimension'] = ({ }; }; +const getSingleColorConfig = (id: string, color = defaultReferenceLineColor) => ({ + columnId: id, + triggerIcon: 'color' as const, + color, +}); + +export const getReferenceLineAccessorColorConfig = (layer: XYReferenceLineLayerConfig) => { + return layer.accessors.map((accessor) => { + const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor); + return getSingleColorConfig(accessor, currentYConfig?.color); + }); +}; + export const getReferenceConfiguration = ({ state, frame, layer, sortedAccessors, - mappedAccessors, }: { state: XYState; frame: FramePublicAPI; layer: XYReferenceLineLayerConfig; sortedAccessors: string[]; - mappedAccessors: AccessorConfig[]; }) => { const idToIndex = sortedAccessors.reduce>((memo, id, index) => { memo[id] = index; @@ -420,11 +432,7 @@ export const getReferenceConfiguration = ({ groups: groupsToShow.map(({ config = [], id, label, dataTestSubj, valid }) => ({ groupId: id, groupLabel: getAxisName(label, { isHorizontal }), - accessors: config.map(({ forAccessor, color }) => ({ - columnId: forAccessor, - color: color || mappedAccessors.find(({ columnId }) => columnId === forAccessor)?.color, - triggerIcon: 'color' as const, - })), + accessors: config.map(({ forAccessor, color }) => getSingleColorConfig(forAccessor, color)), filterOperations: isNumericMetric, supportsMoreColumns: true, required: false, diff --git a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts index dee7899740173..e0984e62cb9cc 100644 --- a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts +++ b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts @@ -16,7 +16,7 @@ import type { XYReferenceLineLayerConfig, } from '../../common/expressions'; import { visualizationTypes } from './types'; -import { getDataLayers, isDataLayer } from './visualization_helpers'; +import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization_helpers'; export function isHorizontalSeries(seriesType: SeriesType) { return ( @@ -53,6 +53,9 @@ export function getIconForSeries(type: SeriesType): EuiIconType { } export const getSeriesColor = (layer: XYLayerConfig, accessor: string) => { + if (isAnnotationsLayer(layer)) { + return layer?.annotations?.find((ann) => ann.id === accessor)?.color || null; + } if (isDataLayer(layer) && layer.splitAccessor) { return null; } diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index fa992d8829b20..2e3db8f2f6f93 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -15,6 +15,7 @@ import { layerTypes } from '../../common'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; import { defaultReferenceLineColor } from './color_assignment'; import { themeServiceMock } from '../../../../../src/core/public/mocks'; +import { eventAnnotationServiceMock } from 'src/plugins/event_annotation/public/mocks'; describe('#toExpression', () => { const xyVisualization = getXyVisualization({ @@ -22,6 +23,7 @@ describe('#toExpression', () => { fieldFormats: fieldFormatsServiceMock.createStartContract(), kibanaTheme: themeServiceMock.createStartContract(), useLegacyTimeAxis: false, + eventAnnotationService: eventAnnotationServiceMock, }); let mockDatasource: ReturnType; let frame: ReturnType; diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index a9c166a9c13eb..ade90ff98e553 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -8,29 +8,40 @@ import { Ast } from '@kbn/interpreter'; import { ScaleType } from '@elastic/charts'; import { PaletteRegistry } from 'src/plugins/charts/public'; +import { EventAnnotationServiceType } from 'src/plugins/event_annotation/public'; import { State } from './types'; import { OperationMetadata, DatasourcePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; import type { ValidLayer, - XYDataLayerConfig, + XYAnnotationLayerConfig, XYReferenceLineLayerConfig, YConfig, + XYDataLayerConfig, } from '../../common/expressions'; import { layerTypes } from '../../common'; import { hasIcon } from './xy_config_panel/shared/icon_select'; import { defaultReferenceLineColor } from './color_assignment'; import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; -import { isDataLayer } from './visualization_helpers'; +import { + getLayerTypeOptions, + getDataLayers, + getReferenceLayers, + getAnnotationsLayers, +} from './visualization_helpers'; +import { defaultAnnotationLabel } from './annotations/config_panel'; +import { getUniqueLabels } from './annotations/helpers'; export const getSortedAccessors = ( datasource: DatasourcePublicAPI, layer: XYDataLayerConfig | XYReferenceLineLayerConfig ) => { const originalOrder = datasource - .getTableSpec() - .map(({ columnId }: { columnId: string }) => columnId) - .filter((columnId: string) => layer.accessors.includes(columnId)); + ? datasource + .getTableSpec() + .map(({ columnId }: { columnId: string }) => columnId) + .filter((columnId: string) => layer.accessors.includes(columnId)) + : layer.accessors; // When we add a column it could be empty, and therefore have no order return Array.from(new Set(originalOrder.concat(layer.accessors))); }; @@ -39,7 +50,8 @@ export const toExpression = ( state: State, datasourceLayers: Record, paletteService: PaletteRegistry, - attributes: Partial<{ title: string; description: string }> = {} + attributes: Partial<{ title: string; description: string }> = {}, + eventAnnotationService: EventAnnotationServiceType ): Ast | null => { if (!state || !state.layers.length) { return null; @@ -49,38 +61,58 @@ export const toExpression = ( state.layers.forEach((layer) => { metadata[layer.layerId] = {}; const datasource = datasourceLayers[layer.layerId]; - datasource.getTableSpec().forEach((column) => { - const operation = datasourceLayers[layer.layerId].getOperationForColumnId(column.columnId); - metadata[layer.layerId][column.columnId] = operation; - }); + if (datasource) { + datasource.getTableSpec().forEach((column) => { + const operation = datasourceLayers[layer.layerId].getOperationForColumnId(column.columnId); + metadata[layer.layerId][column.columnId] = operation; + }); + } }); - return buildExpression(state, metadata, datasourceLayers, paletteService, attributes); + return buildExpression( + state, + metadata, + datasourceLayers, + paletteService, + attributes, + eventAnnotationService + ); +}; + +const simplifiedLayerExpression = { + [layerTypes.DATA]: (layer: XYDataLayerConfig) => ({ ...layer, hide: true }), + [layerTypes.REFERENCELINE]: (layer: XYReferenceLineLayerConfig) => ({ + ...layer, + hide: true, + yConfig: layer.yConfig?.map(({ lineWidth, ...rest }) => ({ + ...rest, + lineWidth: 1, + icon: undefined, + textVisibility: false, + })), + }), + [layerTypes.ANNOTATIONS]: (layer: XYAnnotationLayerConfig) => ({ + ...layer, + hide: true, + annotations: layer.annotations?.map(({ lineWidth, ...rest }) => ({ + ...rest, + lineWidth: 1, + icon: undefined, + textVisibility: false, + })), + }), }; export function toPreviewExpression( state: State, datasourceLayers: Record, - paletteService: PaletteRegistry + paletteService: PaletteRegistry, + eventAnnotationService: EventAnnotationServiceType ) { return toExpression( { ...state, - layers: state.layers.map((layer) => - isDataLayer(layer) - ? { ...layer, hide: true } - : // cap the reference line to 1px - { - ...layer, - hide: true, - yConfig: layer.yConfig?.map(({ lineWidth, ...config }) => ({ - ...config, - lineWidth: 1, - icon: undefined, - textVisibility: false, - })), - } - ), + layers: state.layers.map((layer) => getLayerTypeOptions(layer, simplifiedLayerExpression)), // hide legend for preview legend: { ...state.legend, @@ -90,7 +122,8 @@ export function toPreviewExpression( }, datasourceLayers, paletteService, - {} + {}, + eventAnnotationService ); } @@ -125,23 +158,35 @@ export const buildExpression = ( metadata: Record>, datasourceLayers: Record, paletteService: PaletteRegistry, - attributes: Partial<{ title: string; description: string }> = {} + attributes: Partial<{ title: string; description: string }> = {}, + eventAnnotationService: EventAnnotationServiceType ): Ast | null => { - const validLayers = state.layers + const validDataLayers = getDataLayers(state.layers) .filter((layer): layer is ValidLayer => Boolean(layer.accessors.length)) - .map((layer) => { - if (!datasourceLayers) { - return layer; - } - const sortedAccessors = getSortedAccessors(datasourceLayers[layer.layerId], layer); + .map((layer) => ({ + ...layer, + accessors: getSortedAccessors(datasourceLayers[layer.layerId], layer), + })); + + // sorting doesn't change anything so we don't sort reference layers (TODO: should we make it work?) + const validReferenceLayers = getReferenceLayers(state.layers).filter((layer) => + Boolean(layer.accessors.length) + ); + const uniqueLabels = getUniqueLabels(state.layers); + const validAnnotationsLayers = getAnnotationsLayers(state.layers) + .filter((layer) => Boolean(layer.annotations.length)) + .map((layer) => { return { ...layer, - accessors: sortedAccessors, + annotations: layer.annotations.map((c) => ({ + ...c, + label: uniqueLabels[c.id], + })), }; }); - if (!validLayers.length) { + if (!validDataLayers.length) { return null; } @@ -309,20 +354,25 @@ export const buildExpression = ( valueLabels: [state?.valueLabels || 'hide'], hideEndzones: [state?.hideEndzones || false], valuesInLegend: [state?.valuesInLegend || false], - layers: validLayers.map((layer) => { - if (isDataLayer(layer)) { - return dataLayerToExpression( + layers: [ + ...validDataLayers.map((layer) => + dataLayerToExpression( layer, datasourceLayers[layer.layerId], metadata, paletteService - ); - } - return referenceLineLayerToExpression( - layer, - datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId] - ); - }), + ) + ), + ...validReferenceLayers.map((layer) => + referenceLineLayerToExpression( + layer, + datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId] + ) + ), + ...validAnnotationsLayers.map((layer) => + annotationLayerToExpression(layer, eventAnnotationService) + ), + ], }, }, ], @@ -355,6 +405,41 @@ const referenceLineLayerToExpression = ( }; }; +const annotationLayerToExpression = ( + layer: XYAnnotationLayerConfig, + eventAnnotationService: EventAnnotationServiceType +): Ast => { + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_annotation_layer', + arguments: { + hide: [Boolean(layer.hide)], + layerId: [layer.layerId], + layerType: [layerTypes.ANNOTATIONS], + annotations: layer.annotations + ? layer.annotations.map( + (ann): Ast => + eventAnnotationService.toExpression({ + time: ann.key.timestamp, + label: ann.label || defaultAnnotationLabel, + textVisibility: ann.textVisibility, + icon: ann.icon, + lineStyle: ann.lineStyle, + lineWidth: ann.lineWidth, + color: ann.color, + isHidden: Boolean(ann.isHidden), + }) + ) + : [], + }, + }, + ], + }; +}; + const dataLayerToExpression = ( layer: ValidLayer, datasourceLayer: DatasourcePublicAPI, diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 07e411b1993c9..b93cf317e1b2f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -8,7 +8,7 @@ import { getXyVisualization } from './visualization'; import { Position } from '@elastic/charts'; import { Operation, VisualizeEditorContext, Suggestion, OperationDescriptor } from '../types'; -import type { State, XYSuggestion } from './types'; +import type { State, XYState, XYSuggestion } from './types'; import type { SeriesType, XYDataLayerConfig, @@ -23,6 +23,18 @@ import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks' import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; import { Datatable } from 'src/plugins/expressions'; import { themeServiceMock } from '../../../../../src/core/public/mocks'; +import { eventAnnotationServiceMock } from 'src/plugins/event_annotation/public/mocks'; +import { EventAnnotationConfig } from 'src/plugins/event_annotation/common'; + +const exampleAnnotation: EventAnnotationConfig = { + id: 'an1', + label: 'Event 1', + key: { + type: 'point_in_time', + timestamp: '2022-03-18T08:25:17.140Z', + }, + icon: 'circle', +}; function exampleState(): State { return { @@ -49,6 +61,7 @@ const xyVisualization = getXyVisualization({ fieldFormats: fieldFormatsMock, useLegacyTimeAxis: false, kibanaTheme: themeServiceMock.createStartContract(), + eventAnnotationService: eventAnnotationServiceMock, }); describe('xy_visualization', () => { @@ -149,7 +162,7 @@ describe('xy_visualization', () => { expect(initialState.layers).toHaveLength(1); expect((initialState.layers[0] as XYDataLayerConfig).xAccessor).not.toBeDefined(); - expect(initialState.layers[0].accessors).toHaveLength(0); + expect((initialState.layers[0] as XYDataLayerConfig).accessors).toHaveLength(0); expect(initialState).toMatchInlineSnapshot(` Object { @@ -227,12 +240,63 @@ describe('xy_visualization', () => { describe('#getSupportedLayers', () => { it('should return a double layer types', () => { - expect(xyVisualization.getSupportedLayers()).toHaveLength(2); + expect(xyVisualization.getSupportedLayers()).toHaveLength(3); }); it('should return the icon for the visualization type', () => { expect(xyVisualization.getSupportedLayers()[0].icon).not.toBeUndefined(); }); + describe('annotations', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + frame.datasourceLayers.first.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'a') { + return { + dataType: 'date', + isBucketed: true, + scale: 'interval', + label: 'date_histogram', + isStaticValue: false, + hasTimeShift: false, + }; + } + return null; + }); + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + }); + it('when there is no date histogram annotation layer is disabled', () => { + const supportedAnnotationLayer = xyVisualization + .getSupportedLayers(exampleState()) + .find((a) => a.type === 'annotations'); + expect(supportedAnnotationLayer?.disabled).toBeTruthy(); + }); + it('for data with date histogram annotation layer is enabled and calculates initial dimensions', () => { + const supportedAnnotationLayer = xyVisualization + .getSupportedLayers(exampleState(), frame) + .find((a) => a.type === 'annotations'); + expect(supportedAnnotationLayer?.disabled).toBeFalsy(); + expect(supportedAnnotationLayer?.noDatasource).toBeTruthy(); + expect(supportedAnnotationLayer?.initialDimensions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ groupId: 'xAnnotations', columnId: expect.any(String) }), + ]) + ); + }); + }); }); describe('#getLayerType', () => { @@ -358,6 +422,45 @@ describe('xy_visualization', () => { ], }); }); + + describe('annotations', () => { + it('should add a dimension to a annotation layer', () => { + jest.spyOn(Date, 'now').mockReturnValue(new Date('2022-04-18T11:01:58.135Z').valueOf()); + expect( + xyVisualization.setDimension({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation], + }, + ], + }, + layerId: 'annotation', + groupId: 'xAnnotation', + columnId: 'newCol', + }).layers[0] + ).toEqual({ + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [ + exampleAnnotation, + { + icon: 'triangle', + id: 'newCol', + key: { + timestamp: '2022-04-18T11:01:58.135Z', + type: 'point_in_time', + }, + label: 'Event', + }, + ], + }); + }); + }); }); describe('#updateLayersConfigurationFromContext', () => { @@ -472,9 +575,10 @@ describe('xy_visualization', () => { layerId: 'first', context: newContext, }); - expect(state?.layers[0]).toHaveProperty('seriesType', 'area'); - expect(state?.layers[0]).toHaveProperty('layerType', 'referenceLine'); - expect(state?.layers[0].yConfig).toStrictEqual([ + const firstLayer = state?.layers[0] as XYDataLayerConfig; + expect(firstLayer).toHaveProperty('seriesType', 'area'); + expect(firstLayer).toHaveProperty('layerType', 'referenceLine'); + expect(firstLayer.yConfig).toStrictEqual([ { axisMode: 'right', color: '#68BC00', @@ -695,6 +799,45 @@ describe('xy_visualization', () => { accessors: [], }); }); + it('removes annotation dimension', () => { + expect( + xyVisualization.removeDimension({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'ann', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation, { ...exampleAnnotation, id: 'an2' }], + }, + ], + }, + layerId: 'ann', + columnId: 'an2', + }).layers + ).toEqual([ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'ann', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation], + }, + ]); + }); }); describe('#getConfiguration', () => { @@ -1069,7 +1212,7 @@ describe('xy_visualization', () => { it('should support static value', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].accessors = []; + (state.layers[1] as XYReferenceLineLayerConfig).accessors = []; (state.layers[1] as XYReferenceLineLayerConfig).yConfig = undefined; expect( xyVisualization.getConfiguration({ @@ -1082,7 +1225,7 @@ describe('xy_visualization', () => { it('should return no referenceLine groups for a empty data layer', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].accessors = []; + (state.layers[0] as XYDataLayerConfig).accessors = []; (state.layers[1] as XYReferenceLineLayerConfig).yConfig = undefined; const options = xyVisualization.getConfiguration({ @@ -1358,6 +1501,83 @@ describe('xy_visualization', () => { }); }); + describe('annotations', () => { + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + frame.datasourceLayers.first.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'a') { + return { + dataType: 'date', + isBucketed: true, + scale: 'interval', + label: 'date_histogram', + isStaticValue: false, + hasTimeShift: false, + }; + } + return null; + }); + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + }); + + function getStateWithAnnotationLayer(): State { + return { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + splitAccessor: undefined, + xAccessor: 'a', + accessors: ['b'], + }, + { + layerId: 'annotations', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation], + }, + ], + }; + } + + it('returns configuration correctly', () => { + const state = getStateWithAnnotationLayer(); + const config = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'annotations', + }); + expect(config.groups[0].accessors).toEqual([ + { color: '#f04e98', columnId: 'an1', triggerIcon: 'color' }, + ]); + expect(config.groups[0].invalid).toEqual(false); + }); + + it('When data layer is empty, should return invalid state', () => { + const state = getStateWithAnnotationLayer(); + (state.layers[0] as XYDataLayerConfig).xAccessor = undefined; + const config = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'annotations', + }); + expect(config.groups[0].invalid).toEqual(true); + }); + }); + describe('color assignment', () => { function callConfig(layerConfigOverride: Partial) { const baseState = exampleState(); @@ -1954,4 +2174,87 @@ describe('xy_visualization', () => { `); }); }); + describe('#getUniqueLabels', () => { + it('creates unique labels for single annotations layer with repeating labels', async () => { + const xyState = { + layers: [ + { + layerId: 'layerId', + layerType: 'annotations', + annotations: [ + { + label: 'Event', + id: '1', + }, + { + label: 'Event', + id: '2', + }, + { + label: 'Custom', + id: '3', + }, + ], + }, + ], + } as XYState; + + expect(xyVisualization.getUniqueLabels!(xyState)).toEqual({ + '1': 'Event', + '2': 'Event [1]', + '3': 'Custom', + }); + }); + it('creates unique labels for multiple annotations layers with repeating labels', async () => { + const xyState = { + layers: [ + { + layerId: 'layer1', + layerType: 'annotations', + annotations: [ + { + label: 'Event', + id: '1', + }, + { + label: 'Event', + id: '2', + }, + { + label: 'Custom', + id: '3', + }, + ], + }, + { + layerId: 'layer2', + layerType: 'annotations', + annotations: [ + { + label: 'Event', + id: '4', + }, + { + label: 'Event [1]', + id: '5', + }, + { + label: 'Custom', + id: '6', + }, + ], + }, + ], + } as XYState; + + expect(xyVisualization.getUniqueLabels!(xyState)).toEqual({ + '1': 'Event', + '2': 'Event [1]', + '3': 'Custom', + '4': 'Event [2]', + '5': 'Event [1] [1]', + '6': 'Custom [1]', + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index c9951c24f8a47..78fd50f7cfece 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -13,16 +13,17 @@ import { i18n } from '@kbn/i18n'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { ThemeServiceStart } from 'kibana/public'; +import { EventAnnotationServiceType } from '../../../../../src/plugins/event_annotation/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; -import type { FillStyle } from '../../common/expressions/xy_chart'; +import type { FillStyle, XYLayerConfig } from '../../common/expressions/xy_chart'; import { getSuggestions } from './xy_suggestions'; import { XyToolbar } from './xy_config_panel'; import { DimensionEditor } from './xy_config_panel/dimension_editor'; import { LayerHeader } from './xy_config_panel/layer_header'; import type { Visualization, AccessorConfig, FramePublicAPI } from '../types'; import { State, visualizationTypes, XYSuggestion } from './types'; -import { SeriesType, XYDataLayerConfig, XYLayerConfig, YAxisMode } from '../../common/expressions'; +import { SeriesType, XYDataLayerConfig, YAxisMode } from '../../common/expressions'; import { layerTypes } from '../../common'; import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; @@ -34,6 +35,12 @@ import { getReferenceSupportedLayer, setReferenceDimension, } from './reference_line_helpers'; +import { + getAnnotationsConfiguration, + getAnnotationsSupportedLayer, + setAnnotationsDimension, + getUniqueLabels, +} from './annotations/helpers'; import { checkXAccessorCompatibility, defaultSeriesType, @@ -42,7 +49,9 @@ import { getDescription, getFirstDataLayer, getLayersByType, + getReferenceLayers, getVisualizationType, + isAnnotationsLayer, isBucketed, isDataLayer, isNumericDynamicMetric, @@ -54,14 +63,18 @@ import { import { groupAxesByType } from './axes_configuration'; import { XYState } from '..'; import { ReferenceLinePanel } from './xy_config_panel/reference_line_panel'; +import { DimensionTrigger } from '../shared_components/dimension_trigger'; +import { AnnotationsPanel, defaultAnnotationLabel } from './annotations/config_panel'; export const getXyVisualization = ({ paletteService, fieldFormats, useLegacyTimeAxis, kibanaTheme, + eventAnnotationService, }: { paletteService: PaletteRegistry; + eventAnnotationService: EventAnnotationServiceType; fieldFormats: FieldFormatsStart; useLegacyTimeAxis: boolean; kibanaTheme: ThemeServiceStart; @@ -155,7 +168,11 @@ export const getXyVisualization = ({ }, getSupportedLayers(state, frame) { - return [supportedDataLayer, getReferenceSupportedLayer(state, frame)]; + return [ + supportedDataLayer, + getAnnotationsSupportedLayer(state, frame), + getReferenceSupportedLayer(state, frame), + ]; }, getConfiguration({ state, frame, layerId }) { @@ -164,10 +181,18 @@ export const getXyVisualization = ({ return { groups: [] }; } + if (isAnnotationsLayer(layer)) { + return getAnnotationsConfiguration({ state, frame, layer }); + } + const sortedAccessors: string[] = getSortedAccessors( frame.datasourceLayers[layer.layerId], layer ); + if (isReferenceLayer(layer)) { + return getReferenceConfiguration({ state, frame, layer, sortedAccessors }); + } + const mappedAccessors = getMappedAccessors({ state, frame, @@ -177,11 +202,7 @@ export const getXyVisualization = ({ accessors: sortedAccessors, }); - if (isReferenceLayer(layer)) { - return getReferenceConfiguration({ state, frame, layer, sortedAccessors, mappedAccessors }); - } const dataLayers = getDataLayers(state.layers); - const isHorizontal = isHorizontalChart(state.layers); const { left, right } = groupAxesByType([layer], frame.activeData); // Check locally if it has one accessor OR one accessor per axis @@ -275,6 +296,9 @@ export const getXyVisualization = ({ if (isReferenceLayer(foundLayer)) { return setReferenceDimension(props); } + if (isAnnotationsLayer(foundLayer)) { + return setAnnotationsDimension(props); + } const newLayer = { ...foundLayer }; if (groupId === 'x') { @@ -295,7 +319,7 @@ export const getXyVisualization = ({ updateLayersConfigurationFromContext({ prevState, layerId, context }) { const { chartType, axisPosition, palette, metrics } = context; const foundLayer = prevState?.layers.find((l) => l.layerId === layerId); - if (!foundLayer) { + if (!foundLayer || !isDataLayer(foundLayer)) { return prevState; } const isReferenceLine = metrics.some((metric) => metric.agg === 'static_value'); @@ -377,7 +401,16 @@ export const getXyVisualization = ({ if (!foundLayer) { return prevState; } - const dataLayers = getDataLayers(prevState.layers); + if (isAnnotationsLayer(foundLayer)) { + const newLayer = { ...foundLayer }; + newLayer.annotations = newLayer.annotations.filter(({ id }) => id !== columnId); + + const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); + return { + ...prevState, + layers: newLayers, + }; + } const newLayer = { ...foundLayer }; if (isDataLayer(newLayer)) { if (newLayer.xAccessor === columnId) { @@ -392,15 +425,15 @@ export const getXyVisualization = ({ newLayer.accessors = newLayer.accessors.filter((a) => a !== columnId); } - if (newLayer.yConfig) { - newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId); + if ('yConfig' in newLayer) { + newLayer.yConfig = newLayer.yConfig?.filter(({ forAccessor }) => forAccessor !== columnId); } let newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); // check if there's any reference layer and pull it off if all data layers have no dimensions set // check for data layers if they all still have xAccessors const groupsAvailable = getGroupsAvailableInData( - dataLayers, + getDataLayers(prevState.layers), frame.datasourceLayers, frame?.activeData ); @@ -410,7 +443,9 @@ export const getXyVisualization = ({ (id) => !groupsAvailable[id] ) ) { - newLayers = newLayers.filter((layer) => isDataLayer(layer) || layer.accessors.length); + newLayers = newLayers.filter( + (layer) => isDataLayer(layer) || ('accessors' in layer && layer.accessors.length) + ); } return { @@ -450,9 +485,12 @@ export const getXyVisualization = ({ const layer = props.state.layers.find((l) => l.layerId === props.layerId)!; const dimensionEditor = isReferenceLayer(layer) ? ( + ) : isAnnotationsLayer(layer) ? ( + ) : ( ); + render( {dimensionEditor} @@ -462,8 +500,9 @@ export const getXyVisualization = ({ }, toExpression: (state, layers, attributes) => - toExpression(state, layers, paletteService, attributes), - toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), + toExpression(state, layers, paletteService, attributes, eventAnnotationService), + toPreviewExpression: (state, layers) => + toPreviewExpression(state, layers, paletteService, eventAnnotationService), getErrorMessages(state, datasourceLayers) { // Data error handling below here @@ -504,7 +543,7 @@ export const getXyVisualization = ({ // temporary fix for #87068 errors.push(...checkXAccessorCompatibility(state, datasourceLayers)); - for (const layer of state.layers) { + for (const layer of getDataLayers(state.layers)) { const datasourceAPI = datasourceLayers[layer.layerId]; if (datasourceAPI) { for (const accessor of layer.accessors) { @@ -540,9 +579,10 @@ export const getXyVisualization = ({ return; } - const layers = state.layers; - - const filteredLayers = layers.filter(({ accessors }: XYLayerConfig) => accessors.length > 0); + const filteredLayers = [ + ...getDataLayers(state.layers), + ...getReferenceLayers(state.layers), + ].filter(({ accessors }) => accessors.length > 0); const accessorsWithArrayValues = []; for (const layer of filteredLayers) { const { layerId, accessors } = layer; @@ -569,6 +609,35 @@ export const getXyVisualization = ({ /> )); }, + getUniqueLabels(state) { + return getUniqueLabels(state.layers); + }, + renderDimensionTrigger({ + columnId, + label, + hideTooltip, + invalid, + invalidMessage, + }: { + columnId: string; + label?: string; + hideTooltip?: boolean; + invalid?: boolean; + invalidMessage?: string; + }) { + if (label) { + return ( + + ); + } + return null; + }, }); const getMappedAccessors = ({ @@ -584,7 +653,7 @@ const getMappedAccessors = ({ paletteService: PaletteRegistry; fieldFormats: FieldFormatsStart; state: XYState; - layer: XYLayerConfig; + layer: XYDataLayerConfig; }) => { let mappedAccessors: AccessorConfig[] = accessors.map((accessor) => ({ columnId: accessor, @@ -592,7 +661,7 @@ const getMappedAccessors = ({ if (frame.activeData) { const colorAssignments = getColorAssignments( - state.layers, + getDataLayers(state.layers), { tables: frame.activeData }, fieldFormats.deserialize ); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx index 7446c2a06119c..23c2446ca2363 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx @@ -11,8 +11,12 @@ import { DatasourcePublicAPI, OperationMetadata, VisualizationType } from '../ty import { State, visualizationTypes, XYState } from './types'; import { isHorizontalChart } from './state_helpers'; import { + AnnotationLayerArgs, + DataLayerArgs, SeriesType, + XYAnnotationLayerConfig, XYDataLayerConfig, + XYLayerArgs, XYLayerConfig, XYReferenceLineLayerConfig, } from '../../common/expressions'; @@ -130,9 +134,12 @@ export function checkScaleOperation( export const isDataLayer = (layer: Pick): layer is XYDataLayerConfig => layer.layerType === layerTypes.DATA || !layer.layerType; -export const getDataLayers = (layers: XYLayerConfig[]) => +export const getDataLayers = (layers: Array>) => (layers || []).filter((layer): layer is XYDataLayerConfig => isDataLayer(layer)); +export const getDataLayersArgs = (layers: XYLayerArgs[]) => + (layers || []).filter((layer): layer is DataLayerArgs => isDataLayer(layer)); + export const getFirstDataLayer = (layers: XYLayerConfig[]) => (layers || []).find((layer): layer is XYDataLayerConfig => isDataLayer(layer)); @@ -140,9 +147,34 @@ export const isReferenceLayer = ( layer: Pick ): layer is XYReferenceLineLayerConfig => layer.layerType === layerTypes.REFERENCELINE; -export const getReferenceLayers = (layers: XYLayerConfig[]) => +export const getReferenceLayers = (layers: Array>) => (layers || []).filter((layer): layer is XYReferenceLineLayerConfig => isReferenceLayer(layer)); +export const isAnnotationsLayer = ( + layer: Pick +): layer is XYAnnotationLayerConfig => layer.layerType === layerTypes.ANNOTATIONS; + +export const getAnnotationsLayers = (layers: Array>) => + (layers || []).filter((layer): layer is XYAnnotationLayerConfig => isAnnotationsLayer(layer)); + +export const getAnnotationsLayersArgs = (layers: XYLayerArgs[]) => + (layers || []).filter((layer): layer is AnnotationLayerArgs => isAnnotationsLayer(layer)); + +export interface LayerTypeToLayer { + [layerTypes.DATA]: (layer: XYDataLayerConfig) => XYDataLayerConfig; + [layerTypes.REFERENCELINE]: (layer: XYReferenceLineLayerConfig) => XYReferenceLineLayerConfig; + [layerTypes.ANNOTATIONS]: (layer: XYAnnotationLayerConfig) => XYAnnotationLayerConfig; +} + +export const getLayerTypeOptions = (layer: XYLayerConfig, options: LayerTypeToLayer) => { + if (isDataLayer(layer)) { + return options[layerTypes.DATA](layer); + } else if (isReferenceLayer(layer)) { + return options[layerTypes.REFERENCELINE](layer); + } + return options[layerTypes.ANNOTATIONS](layer); +}; + export function getVisualizationType(state: State): VisualizationType | 'mixed' { if (!state.layers.length) { return ( @@ -255,6 +287,11 @@ const newLayerFn = { layerType: layerTypes.REFERENCELINE, accessors: [], }), + [layerTypes.ANNOTATIONS]: ({ layerId }: { layerId: string }): XYAnnotationLayerConfig => ({ + layerId, + layerType: layerTypes.ANNOTATIONS, + annotations: [], + }), }; export function newLayerState({ diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx index 8aa2aaf16ae5f..b448ebfbd455e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx @@ -9,6 +9,7 @@ import React, { useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon } from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; +import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public'; import type { VisualizationDimensionEditorProps } from '../../types'; import { State } from '../types'; import { FormatFactory } from '../../../common'; @@ -20,7 +21,7 @@ import { } from '../color_assignment'; import { getSortedAccessors } from '../to_expression'; import { TooltipWrapper } from '../../shared_components'; -import { isReferenceLayer } from '../visualization_helpers'; +import { isReferenceLayer, isAnnotationsLayer, getDataLayers } from '../visualization_helpers'; const tooltipContent = { auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', { @@ -62,15 +63,17 @@ export const ColorPicker = ({ if (overwriteColor || !frame.activeData) return overwriteColor; if (isReferenceLayer(layer)) { return defaultReferenceLineColor; + } else if (isAnnotationsLayer(layer)) { + return defaultAnnotationColor; } const sortedAccessors: string[] = getSortedAccessors( - frame.datasourceLayers[layer.layerId], + frame.datasourceLayers[layer.layerId] ?? layer.accessors, layer ); const colorAssignments = getColorAssignments( - state.layers, + getDataLayers(state.layers), { tables: frame.activeData }, formatFactory ); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx index 465a627fa33b2..c4e5268cfb8af 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx @@ -16,8 +16,9 @@ import { trackUiEvent } from '../../lens_ui_telemetry'; import { StaticHeader } from '../../shared_components'; import { ToolbarButton } from '../../../../../../src/plugins/kibana_react/public'; import { LensIconChartBarReferenceLine } from '../../assets/chart_bar_reference_line'; +import { LensIconChartBarAnnotations } from '../../assets/chart_bar_annotations'; import { updateLayer } from '.'; -import { isReferenceLayer } from '../visualization_helpers'; +import { isAnnotationsLayer, isReferenceLayer } from '../visualization_helpers'; export function LayerHeader(props: VisualizationLayerWidgetProps) { const layer = props.state.layers.find((l) => l.layerId === props.layerId); @@ -26,6 +27,8 @@ export function LayerHeader(props: VisualizationLayerWidgetProps) { } if (isReferenceLayer(layer)) { return ; + } else if (isAnnotationsLayer(layer)) { + return ; } return ; } @@ -41,6 +44,17 @@ function ReferenceLayerHeader() { ); } +function AnnotationsLayerHeader() { + return ( + + ); +} + function DataLayerHeader(props: VisualizationLayerWidgetProps) { const [isPopoverOpen, setPopoverIsOpen] = useState(false); const { state, layerId } = props; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx index f00d60b0dc814..78020034c3d43 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx @@ -70,6 +70,7 @@ export const ReferenceLinePanel = ( return ( <> + {' '} ; + +export const euiIconsSet = [ { value: 'empty', label: i18n.translate('xpack.lens.xyChart.iconSelect.noIconLabel', { @@ -70,29 +72,35 @@ const icons = [ }, ]; -const IconView = (props: { value?: string; label: string }) => { +const IconView = (props: { value?: string; label: string; icon?: IconType }) => { if (!props.value) return null; return ( - - - {` ${props.label}`} - + + + + + {props.label} + ); }; export const IconSelect = ({ value, onChange, + customIconSet = euiIconsSet, }: { value?: string; onChange: (newIcon: string) => void; + customIconSet?: IconSet; }) => { - const selectedIcon = icons.find((option) => value === option.value) || icons[0]; + const selectedIcon = + customIconSet.find((option) => value === option.value) || + customIconSet.find((option) => option.value === 'empty')!; return ( { onChange(selection[0].value!); @@ -100,7 +108,11 @@ export const IconSelect = ({ singleSelection={{ asPlainText: true }} renderOption={IconView} compressed - prepend={hasIcon(selectedIcon.value) ? : undefined} + prepend={ + hasIcon(selectedIcon.value) ? ( + + ) : undefined + } /> ); }; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx index db01a027d8fec..766d5462db787 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx @@ -40,8 +40,8 @@ export const LineStyleSettings = ({ defaultMessage: 'Line', })} > - - + + { @@ -49,9 +49,8 @@ export const LineStyleSettings = ({ }} /> - + void; isHorizontal: boolean; + customIconSet?: IconSet; }) => { return ( <> @@ -133,13 +136,15 @@ export const MarkerDecorationSettings = ({ })} > { setConfig({ icon: newIcon }); }} /> - {hasIcon(currentConfig?.icon) || currentConfig?.textVisibility ? ( + {currentConfig?.iconPosition && + (hasIcon(currentConfig?.icon) || currentConfig?.textVisibility) ? ( { @@ -533,6 +535,60 @@ describe('xy_suggestions', () => { ); }); + test('passes annotation layer without modifying it', () => { + const annotationLayer: XYAnnotationLayerConfig = { + layerId: 'second', + layerType: layerTypes.ANNOTATIONS, + annotations: [ + { + id: '1', + key: { + type: 'point_in_time', + timestamp: '2020-20-22', + }, + label: 'annotation', + }, + ], + }; + const currentState: XYState = { + legend: { isVisible: true, position: 'bottom' }, + valueLabels: 'hide', + preferredSeriesType: 'bar', + fittingFunction: 'None', + layers: [ + { + accessors: ['price'], + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'bar', + splitAccessor: 'date', + xAccessor: 'product', + }, + annotationLayer, + ], + }; + const suggestions = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + }, + state: currentState, + keptLayerIds: [], + }); + + suggestions.every((suggestion) => + expect(suggestion.state.layers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layerType: layerTypes.ANNOTATIONS, + }), + ]) + ) + ); + }); + test('includes passed in palette for split charts if specified', () => { const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' }; const [suggestion] = getSuggestions({ diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 1578442b52815..bd5a37c206c6c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -521,7 +521,10 @@ function buildSuggestion({ const keptLayers = currentState ? currentState.layers // Remove layers that aren't being suggested - .filter((layer) => keptLayerIds.includes(layer.layerId)) + .filter( + (layer) => + keptLayerIds.includes(layer.layerId) || layer.layerType === layerTypes.ANNOTATIONS + ) // Update in place .map((layer) => (layer.layerId === layerId ? newLayer : layer)) // Replace the seriesType on all previous layers diff --git a/x-pack/plugins/lens/server/expressions/expressions.ts b/x-pack/plugins/lens/server/expressions/expressions.ts index 84e238b3eb15e..c68fed23a7fdb 100644 --- a/x-pack/plugins/lens/server/expressions/expressions.ts +++ b/x-pack/plugins/lens/server/expressions/expressions.ts @@ -12,6 +12,7 @@ import { yAxisConfig, dataLayerConfig, referenceLineLayerConfig, + annotationLayerConfig, formatColumn, legendConfig, renameColumns, @@ -40,6 +41,7 @@ export const setupExpressions = ( yAxisConfig, dataLayerConfig, referenceLineLayerConfig, + annotationLayerConfig, formatColumn, legendConfig, renameColumns, diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index 583e2963a1ca7..76e25f8b08639 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -1,4 +1,3 @@ - { "extends": "../../../tsconfig.base.json", "compilerOptions": { @@ -15,31 +14,86 @@ "../../../typings/**/*" ], "references": [ - { "path": "../spaces/tsconfig.json" }, - { "path": "../../../src/core/tsconfig.json" }, - { "path": "../task_manager/tsconfig.json" }, - { "path": "../global_search/tsconfig.json"}, - { "path": "../saved_objects_tagging/tsconfig.json"}, - { "path": "../../../src/plugins/data/tsconfig.json"}, - { "path": "../../../src/plugins/data_views/tsconfig.json"}, - { "path": "../../../src/plugins/data_view_field_editor/tsconfig.json"}, - { "path": "../../../src/plugins/charts/tsconfig.json"}, - { "path": "../../../src/plugins/expressions/tsconfig.json"}, - { "path": "../../../src/plugins/navigation/tsconfig.json" }, - { "path": "../../../src/plugins/url_forwarding/tsconfig.json" }, - { "path": "../../../src/plugins/visualizations/tsconfig.json" }, - { "path": "../../../src/plugins/dashboard/tsconfig.json" }, - { "path": "../../../src/plugins/ui_actions/tsconfig.json" }, - { "path": "../../../src/plugins/embeddable/tsconfig.json" }, - { "path": "../../../src/plugins/share/tsconfig.json" }, - { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, - { "path": "../../../src/plugins/saved_objects/tsconfig.json"}, - { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, - { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, - { "path": "../../../src/plugins/embeddable/tsconfig.json"}, - { "path": "../../../src/plugins/presentation_util/tsconfig.json"}, - { "path": "../../../src/plugins/field_formats/tsconfig.json"}, - { "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json"}, - { "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json"} + { + "path": "../spaces/tsconfig.json" + }, + { + "path": "../../../src/core/tsconfig.json" + }, + { + "path": "../task_manager/tsconfig.json" + }, + { + "path": "../global_search/tsconfig.json" + }, + { + "path": "../saved_objects_tagging/tsconfig.json" + }, + { + "path": "../../../src/plugins/data/tsconfig.json" + }, + { + "path": "../../../src/plugins/data_views/tsconfig.json" + }, + { + "path": "../../../src/plugins/data_view_field_editor/tsconfig.json" + }, + { + "path": "../../../src/plugins/charts/tsconfig.json" + }, + { + "path": "../../../src/plugins/expressions/tsconfig.json" + }, + { + "path": "../../../src/plugins/navigation/tsconfig.json" + }, + { + "path": "../../../src/plugins/url_forwarding/tsconfig.json" + }, + { + "path": "../../../src/plugins/visualizations/tsconfig.json" + }, + { + "path": "../../../src/plugins/dashboard/tsconfig.json" + }, + { + "path": "../../../src/plugins/ui_actions/tsconfig.json" + }, + { + "path": "../../../src/plugins/embeddable/tsconfig.json" + }, + { + "path": "../../../src/plugins/share/tsconfig.json" + }, + { + "path": "../../../src/plugins/usage_collection/tsconfig.json" + }, + { + "path": "../../../src/plugins/saved_objects/tsconfig.json" + }, + { + "path": "../../../src/plugins/kibana_utils/tsconfig.json" + }, + { + "path": "../../../src/plugins/kibana_react/tsconfig.json" + }, + { + "path": "../../../src/plugins/embeddable/tsconfig.json" + }, + { + "path": "../../../src/plugins/presentation_util/tsconfig.json" + }, + { + "path": "../../../src/plugins/field_formats/tsconfig.json" + }, + { + "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json" + }, + { + "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json" + }, + { + "path": "../../../src/plugins/event_annotation/tsconfig.json" + } ] -} +} \ No newline at end of file diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts index bb4ad821b39cc..69b157835e882 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts @@ -48,9 +48,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); await waitForNextUpdate(); @@ -86,9 +83,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); // NOTE: First `waitForNextUpdate` is initialization @@ -112,7 +106,7 @@ describe('useExceptionLists', () => { }); }); - test('fetches trusted apps lists if "showTrustedApps" is true', async () => { + test('does not fetch specific list id if it is added to the hideLists array', async () => { const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); await act(async () => { @@ -120,6 +114,7 @@ describe('useExceptionLists', () => { useExceptionLists({ errorMessage: 'Uh oh', filterOptions: {}, + hideLists: ['listId-1'], http: mockKibanaHttpService, initialPagination: { page: 1, @@ -128,9 +123,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: true, }) ); // NOTE: First `waitForNextUpdate` is initialization @@ -140,192 +132,7 @@ describe('useExceptionLists', () => { expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ filters: - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('does not fetch trusted apps lists if "showTrustedApps" is false', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('fetches event filters lists if "showEventFilters" is true', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: true, - showHostIsolationExceptions: false, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('does not fetch event filters lists if "showEventFilters" is false', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('fetches host isolation exceptions lists if "hostIsolationExceptionsFilter" is true', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: true, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('does not fetch host isolation exceptions lists if "showHostIsolationExceptions" is false', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', + '(not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)', http: mockKibanaHttpService, namespaceTypes: 'single,agnostic', pagination: { page: 1, perPage: 20 }, @@ -345,6 +152,7 @@ describe('useExceptionLists', () => { created_by: 'Moi', name: 'Sample Endpoint', }, + hideLists: ['listId-1'], http: mockKibanaHttpService, initialPagination: { page: 1, @@ -353,9 +161,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); // NOTE: First `waitForNextUpdate` is initialization @@ -365,7 +170,7 @@ describe('useExceptionLists', () => { expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ filters: - '(exception-list.attributes.created_by:Moi OR exception-list-agnostic.attributes.created_by:Moi) AND (exception-list.attributes.name.text:Sample Endpoint OR exception-list-agnostic.attributes.name.text:Sample Endpoint) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', + '(exception-list.attributes.created_by:Moi OR exception-list-agnostic.attributes.created_by:Moi) AND (exception-list.attributes.name.text:Sample Endpoint OR exception-list-agnostic.attributes.name.text:Sample Endpoint) AND (not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)', http: mockKibanaHttpService, namespaceTypes: 'single,agnostic', pagination: { page: 1, perPage: 20 }, @@ -381,16 +186,7 @@ describe('useExceptionLists', () => { UseExceptionListsProps, ReturnExceptionLists >( - ({ - errorMessage, - filterOptions, - http, - initialPagination, - namespaceTypes, - notifications, - showEventFilters, - showTrustedApps, - }) => + ({ errorMessage, filterOptions, http, initialPagination, namespaceTypes, notifications }) => useExceptionLists({ errorMessage, filterOptions, @@ -398,9 +194,6 @@ describe('useExceptionLists', () => { initialPagination, namespaceTypes, notifications, - showEventFilters, - showHostIsolationExceptions: false, - showTrustedApps, }), { initialProps: { @@ -414,9 +207,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }, } ); @@ -436,9 +226,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }); // NOTE: Only need one call here because hook already initilaized await waitForNextUpdate(); @@ -465,9 +252,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); // NOTE: First `waitForNextUpdate` is initialization @@ -505,9 +289,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); // NOTE: First `waitForNextUpdate` is initialization diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx index 48779569131d6..2fdf0a07f4647 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx @@ -27,39 +27,47 @@ export function ObservabilityStatusBoxes({ boxes }: ObservabilityStatusProps) { return ( - - -

- -

-
-
- {noHasDataBoxes.map((box) => ( - - - - ))} + {noHasDataBoxes.length > 0 && ( + <> + + +

+ +

+
+
+ {noHasDataBoxes.map((box) => ( + + + + ))} + + )} - {noHasDataBoxes.length > 0 && } + {noHasDataBoxes.length > 0 && hasDataBoxes.length > 0 && } - - -

- -

-
-
- {hasDataBoxes.map((box) => ( - - - - ))} + {hasDataBoxes.length > 0 && ( + <> + + +

+ +

+
+
+ {hasDataBoxes.map((box) => ( + + + + ))} + + )}
); } diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx new file mode 100644 index 0000000000000..6e79c3691402a --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx @@ -0,0 +1,63 @@ +/* + * 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 { render, screen, fireEvent } from '@testing-library/react'; +import { HasDataContextValue } from '../../../context/has_data_context'; +import * as hasDataHook from '../../../hooks/use_has_data'; +import { ObservabilityStatusProgress } from './observability_status_progress'; +import { I18nProvider } from '@kbn/i18n-react'; + +describe('ObservabilityStatusProgress', () => { + const onViewDetailsClickFn = jest.fn(); + + beforeEach(() => { + jest.spyOn(hasDataHook, 'useHasData').mockReturnValue({ + hasDataMap: { + apm: { hasData: true, status: 'success' }, + synthetics: { hasData: true, status: 'success' }, + infra_logs: { hasData: undefined, status: 'success' }, + infra_metrics: { hasData: true, status: 'success' }, + ux: { hasData: undefined, status: 'success' }, + alert: { hasData: false, status: 'success' }, + }, + hasAnyData: true, + isAllRequestsComplete: true, + onRefreshTimeRange: () => {}, + forceUpdate: '', + } as HasDataContextValue); + }); + it('should render the progress', () => { + render( + + + + ); + const progressBar = screen.getByRole('progressbar') as HTMLProgressElement; + expect(progressBar).toBeInTheDocument(); + expect(progressBar.value).toBe(50); + }); + + it('should call the onViewDetailsCallback when view details button is clicked', () => { + render( + + + + ); + fireEvent.click(screen.getByText('View details')); + expect(onViewDetailsClickFn).toHaveBeenCalled(); + }); + + it('should hide the component when dismiss button is clicked', () => { + render( + + + + ); + fireEvent.click(screen.getByText('Dismiss')); + expect(screen.queryByTestId('status-progress')).toBe(null); + }); +}); diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx new file mode 100644 index 0000000000000..81f08537c775f --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx @@ -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 React, { useState, useEffect } from 'react'; +import { + EuiPanel, + EuiProgress, + EuiTitle, + EuiButtonEmpty, + EuiButton, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { reduce } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useHasData } from '../../../hooks/use_has_data'; +import { useUiTracker } from '../../../hooks/use_track_metric'; + +const LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY = 'HIDE_GUIDED_SETUP'; + +interface ObservabilityStatusProgressProps { + onViewDetailsClick: () => void; +} +export function ObservabilityStatusProgress({ + onViewDetailsClick, +}: ObservabilityStatusProgressProps) { + const { hasDataMap, isAllRequestsComplete } = useHasData(); + const trackMetric = useUiTracker({ app: 'observability-overview' }); + const hideGuidedSetupLocalStorageKey = window.localStorage.getItem( + LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY + ); + const [isGuidedSetupHidden, setIsGuidedSetupHidden] = useState( + JSON.parse(hideGuidedSetupLocalStorageKey || 'false') + ); + const [progress, setProgress] = useState(0); + + useEffect(() => { + const totalCounts = Object.keys(hasDataMap); + if (isAllRequestsComplete) { + const hasDataCount = reduce( + hasDataMap, + (result, value) => { + return value?.hasData ? result + 1 : result; + }, + 0 + ); + + const percentage = (hasDataCount / totalCounts.length) * 100; + setProgress(isFinite(percentage) ? percentage : 0); + } + }, [isAllRequestsComplete, hasDataMap]); + + const hideGuidedSetup = () => { + window.localStorage.setItem(LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY, 'true'); + setIsGuidedSetupHidden(true); + trackMetric({ metric: 'guided_setup_progress_dismiss' }); + }; + + const showDetails = () => { + onViewDetailsClick(); + trackMetric({ metric: 'guided_setup_progress_view_details' }); + }; + + return !isGuidedSetupHidden ? ( + <> + + + + + + +

+ +

+
+ +

+ +

+
+
+ + + + + + + + + + + + + + +
+
+ + + ) : null; +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts index b81046df99d28..53b2f68821710 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts @@ -6,6 +6,7 @@ */ import { useEffect, useState, useCallback } from 'react'; +import { isEmpty } from 'lodash'; import { loadRules, Rule } from '../../../triggers_actions_ui/public'; import { RULES_LOAD_ERROR } from '../pages/rules/translations'; import { FetchRulesProps } from '../pages/rules/types'; @@ -19,7 +20,13 @@ interface RuleState { totalItemCount: number; } -export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort }: FetchRulesProps) { +export function useFetchRules({ + searchText, + ruleLastResponseFilter, + setPage, + page, + sort, +}: FetchRulesProps) { const { http } = useKibana().services; const [rulesState, setRulesState] = useState({ @@ -29,6 +36,9 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort } totalItemCount: 0, }); + const [noData, setNoData] = useState(true); + const [initialLoad, setInitialLoad] = useState(true); + const fetchRules = useCallback(async () => { setRulesState((oldState) => ({ ...oldState, isLoading: true })); @@ -47,10 +57,18 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort } data: response.data, totalItemCount: response.total, })); + + if (!response.data?.length && page.index > 0) { + setPage({ ...page, index: 0 }); + } + const isFilterApplied = !(isEmpty(searchText) && isEmpty(ruleLastResponseFilter)); + + setNoData(response.data.length === 0 && !isFilterApplied); } catch (_e) { setRulesState((oldState) => ({ ...oldState, isLoading: false, error: RULES_LOAD_ERROR })); } - }, [http, page, searchText, ruleLastResponseFilter, sort]); + setInitialLoad(false); + }, [http, page, setPage, searchText, ruleLastResponseFilter, sort]); useEffect(() => { fetchRules(); }, [fetchRules]); @@ -59,5 +77,7 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort } rulesState, reload: fetchRules, setRulesState, + noData, + initialLoad, }; } diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index cf6ae92d1b9c8..939223feb87c0 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -44,6 +44,7 @@ interface RuleStatsState { disabled: number; muted: number; error: number; + snoozed: number; } export interface TopAlert { @@ -90,6 +91,7 @@ function AlertsPage() { disabled: 0, muted: 0, error: 0, + snoozed: 0, }); useEffect(() => { @@ -111,18 +113,21 @@ function AlertsPage() { const response = await loadRuleAggregations({ http, }); - const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus } = response; - if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus) { + const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus, ruleSnoozedStatus } = + response; + if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus && ruleSnoozedStatus) { const total = Object.values(ruleExecutionStatus).reduce((acc, value) => acc + value, 0); const { disabled } = ruleEnabledStatus; const { muted } = ruleMutedStatus; const { error } = ruleExecutionStatus; + const { snoozed } = ruleSnoozedStatus; setRuleStats({ ...ruleStats, total, disabled, muted, error, + snoozed, }); } setRuleStatsLoading(false); @@ -263,9 +268,9 @@ function AlertsPage() { data-test-subj="statDisabled" />, ; @@ -145,6 +146,7 @@ export function OverviewPage({ routeParams }: Props) { {hasData && ( <> + setIsFlyoutVisible(true)} /> + + + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/name.tsx b/x-pack/plugins/observability/public/pages/rules/components/name.tsx index 2b1f831256910..cbde68ea27eb4 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/name.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/name.tsx @@ -6,8 +6,7 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiBadge } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import { RuleNameProps } from '../types'; import { useKibana } from '../../../utils/kibana_react'; @@ -34,17 +33,5 @@ export function Name({ name, rule }: RuleNameProps) {
); - return ( - <> - {link} - {rule.enabled && rule.muteAll && ( - - - - )} - - ); + return <>{link}; } diff --git a/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx new file mode 100644 index 0000000000000..b9c0e24160004 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx @@ -0,0 +1,69 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { EuiButton, EuiEmptyPrompt, EuiLink, EuiButtonEmpty, EuiPageTemplate } from '@elastic/eui'; + +export function NoDataPrompt({ + onCTAClicked, + documentationLink, +}: { + onCTAClicked: () => void; + documentationLink: string; +}) { + return ( + + + + + } + body={ +

+ +

+ } + actions={[ + + + , + + + Documentation + + , + ]} + /> +
+ ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx new file mode 100644 index 0000000000000..edfe1c6840d8b --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { EuiEmptyPrompt, EuiPageTemplate } from '@elastic/eui'; + +export function NoPermissionPrompt() { + return ( + + + + + } + body={ +

+ +

+ } + /> +
+ ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/status.tsx b/x-pack/plugins/observability/public/pages/rules/components/status.tsx index abc2dc8bfa492..612d6f8f30bdd 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/status.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/status.tsx @@ -5,19 +5,28 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiBadge } from '@elastic/eui'; +import { noop } from 'lodash/fp'; import { StatusProps } from '../types'; import { statusMap } from '../config'; +import { RULES_CHANGE_STATUS } from '../translations'; -export function Status({ type, onClick }: StatusProps) { +export function Status({ type, disabled, onClick }: StatusProps) { + const props = useMemo( + () => ({ + color: statusMap[type].color, + ...(!disabled ? { onClick } : { onClick: noop }), + ...(!disabled ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), + ...(!disabled ? { iconOnClick: onClick } : { iconOnClick: noop }), + }), + [disabled, onClick, type] + ); return ( {statusMap[type].label} diff --git a/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx b/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx index 49761d7c43154..c7bd29d85b17a 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx @@ -18,19 +18,26 @@ import { statusMap } from '../config'; export function StatusContext({ item, + disabled = false, onStatusChanged, enableRule, disableRule, muteRule, + unMuteRule, }: StatusContextProps) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); - const currentStatus = item.enabled ? RuleStatus.enabled : RuleStatus.disabled; + let currentStatus: RuleStatus; + if (item.enabled) { + currentStatus = item.muteAll ? RuleStatus.snoozed : RuleStatus.enabled; + } else { + currentStatus = RuleStatus.disabled; + } const popOverButton = useMemo( - () => , - [currentStatus, togglePopover] + () => , + [disabled, currentStatus, togglePopover] ); const onContextMenuItemClick = useCallback( @@ -41,15 +48,30 @@ export function StatusContext({ if (status === RuleStatus.enabled) { await enableRule({ ...item, enabled: true }); + if (item.muteAll) { + await unMuteRule({ ...item, muteAll: false }); + } } else if (status === RuleStatus.disabled) { await disableRule({ ...item, enabled: false }); + } else if (status === RuleStatus.snoozed) { + await muteRule({ ...item, muteAll: true }); } setIsUpdating(false); onStatusChanged(status); } }, - [item, togglePopover, enableRule, disableRule, currentStatus, onStatusChanged] + [ + item, + togglePopover, + enableRule, + disableRule, + muteRule, + unMuteRule, + currentStatus, + onStatusChanged, + ] ); + const panelItems = useMemo( () => Object.values(RuleStatus).map((status: RuleStatus) => ( @@ -57,6 +79,7 @@ export function StatusContext({ icon={status === currentStatus ? 'check' : 'empty'} key={status} onClick={() => onContextMenuItemClick(status)} + disabled={status === RuleStatus.snoozed && currentStatus === RuleStatus.disabled} > {statusMap[status].label} diff --git a/x-pack/plugins/observability/public/pages/rules/config.ts b/x-pack/plugins/observability/public/pages/rules/config.ts index afff097776e19..736f538ee7b21 100644 --- a/x-pack/plugins/observability/public/pages/rules/config.ts +++ b/x-pack/plugins/observability/public/pages/rules/config.ts @@ -13,6 +13,9 @@ import { RULE_STATUS_PENDING, RULE_STATUS_UNKNOWN, RULE_STATUS_WARNING, + RULE_STATUS_ENABLED, + RULE_STATUS_DISABLED, + RULE_STATUS_SNOOZED_INDEFINITELY, } from './translations'; import { AlertExecutionStatuses } from '../../../../alerting/common'; import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/public'; @@ -20,11 +23,15 @@ import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/p export const statusMap: Status = { [RuleStatus.enabled]: { color: 'primary', - label: 'Enabled', + label: RULE_STATUS_ENABLED, }, [RuleStatus.disabled]: { color: 'default', - label: 'Disabled', + label: RULE_STATUS_DISABLED, + }, + [RuleStatus.snoozed]: { + color: 'warning', + label: RULE_STATUS_SNOOZED_INDEFINITELY, }, }; @@ -93,3 +100,8 @@ export function convertRulesToTableItems( enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, })); } + +type Capabilities = Record; + +export const hasExecuteActionsCapability = (capabilities: Capabilities) => + capabilities?.actions?.execute; diff --git a/x-pack/plugins/observability/public/pages/rules/index.tsx b/x-pack/plugins/observability/public/pages/rules/index.tsx index 8c44fa90fb3d1..21664ca63507d 100644 --- a/x-pack/plugins/observability/public/pages/rules/index.tsx +++ b/x-pack/plugins/observability/public/pages/rules/index.tsx @@ -32,6 +32,9 @@ import { ExecutionStatus } from './components/execution_status'; import { LastRun } from './components/last_run'; import { EditRuleFlyout } from './components/edit_rule_flyout'; import { DeleteModalConfirmation } from './components/delete_modal_confirmation'; +import { NoDataPrompt } from './components/prompts/no_data_prompt'; +import { NoPermissionPrompt } from './components/prompts/no_permission_prompt'; +import { CenterJustifiedSpinner } from './components/center_justified_spinner'; import { deleteRules, RuleTableItem, @@ -39,6 +42,7 @@ import { disableRule, muteRule, useLoadRuleTypes, + unmuteRule, } from '../../../../triggers_actions_ui/public'; import { AlertExecutionStatus, ALERTS_FEATURE_ID } from '../../../../alerting/common'; import { Pagination } from './types'; @@ -46,6 +50,7 @@ import { DEFAULT_SEARCH_PAGE_SIZE, convertRulesToTableItems, OBSERVABILITY_SOLUTIONS, + hasExecuteActionsCapability, } from './config'; import { LAST_RESPONSE_COLUMN_TITLE, @@ -73,9 +78,12 @@ export function RulesPage() { http, docLinks, triggersActionsUi, + application: { capabilities }, notifications: { toasts }, } = useKibana().services; - + const documentationLink = docLinks.links.alerting.guide; + const ruleTypeRegistry = triggersActionsUi.ruleTypeRegistry; + const canExecuteActions = hasExecuteActionsCapability(capabilities); const [page, setPage] = useState({ index: 0, size: DEFAULT_SEARCH_PAGE_SIZE }); const [sort, setSort] = useState['sort']>({ field: 'name', @@ -90,6 +98,9 @@ export function RulesPage() { const [rulesToDelete, setRulesToDelete] = useState([]); const [createRuleFlyoutVisibility, setCreateRuleFlyoutVisibility] = useState(false); + const isRuleTypeEditableInContext = (ruleTypeId: string) => + ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; + const onRuleEdit = (ruleItem: RuleTableItem) => { setCurrentRuleToEdit(ruleItem); }; @@ -102,14 +113,22 @@ export function RulesPage() { setRefreshInterval(refreshIntervalChanged); }; - const { rulesState, setRulesState, reload } = useFetchRules({ + const { rulesState, setRulesState, reload, noData, initialLoad } = useFetchRules({ searchText, ruleLastResponseFilter, page, + setPage, sort, }); const { data: rules, totalItemCount, error } = rulesState; - const { ruleTypeIndex } = useLoadRuleTypes({ filteredSolutions: OBSERVABILITY_SOLUTIONS }); + const { ruleTypeIndex, ruleTypes } = useLoadRuleTypes({ + filteredSolutions: OBSERVABILITY_SOLUTIONS, + }); + const authorizedRuleTypes = [...ruleTypes.values()]; + + const authorizedToCreateAnyRules = authorizedRuleTypes.some( + (ruleType) => ruleType.authorizedConsumers[ALERTS_FEATURE_ID]?.all + ); useEffect(() => { const interval = setInterval(() => { @@ -161,11 +180,13 @@ export function RulesPage() { render: (_enabled: boolean, item: RuleTableItem) => { return ( reload()} enableRule={async () => await enableRule({ http, id: item.id })} disableRule={async () => await disableRule({ http, id: item.id })} muteRule={async () => await muteRule({ http, id: item.id })} + unMuteRule={async () => await unmuteRule({ http, id: item.id })} /> ); }, @@ -180,6 +201,9 @@ export function RulesPage() { { + if (noData && !rulesState.isLoading) { + return authorizedToCreateAnyRules ? ( + setCreateRuleFlyoutVisibility(true)} + /> + ) : ( + + ); + } + if (initialLoad) { + return ; + } + return ( + <> + + + { + setInputText(e.target.value); + if (e.target.value === '') { + setSearchText(e.target.value); + } + }} + onKeyUp={(e) => { + if (e.keyCode === ENTER_KEY) { + setSearchText(inputText); + } + }} + placeholder={SEARCH_PLACEHOLDER} + /> + + + setRuleLastResponseFilter(ids)} + /> + + + + + + , + + + + + + + + + + + + + + + + setPage(index)} + sort={sort} + onSortChange={(changedSort) => { + setSort(changedSort); + }} + /> + + + + ); + }; + return ( ), rightSideItems: [ - setCreateRuleFlyoutVisibility(true)} - > - - , + authorizedToCreateAnyRules && ( + setCreateRuleFlyoutVisibility(true)} + > + + + ), - - - { - setInputText(e.target.value); - if (e.target.value === '') { - setSearchText(e.target.value); - } - }} - onKeyUp={(e) => { - if (e.keyCode === ENTER_KEY) { - setSearchText(inputText); - } - }} - placeholder={SEARCH_PLACEHOLDER} - /> - - - setRuleLastResponseFilter(ids)} - /> - - - - - - , - - - - - - - - - - - - - - - - setPage(index)} - sort={sort} - onSortChange={(changedSort) => { - setSort(changedSort); - }} - /> - - + {getRulesTable()} {error && toasts.addDanger({ title: error, diff --git a/x-pack/plugins/observability/public/pages/rules/translations.ts b/x-pack/plugins/observability/public/pages/rules/translations.ts index b72d03bf8e566..36f8ff62f1a4c 100644 --- a/x-pack/plugins/observability/public/pages/rules/translations.ts +++ b/x-pack/plugins/observability/public/pages/rules/translations.ts @@ -53,6 +53,27 @@ export const RULE_STATUS_WARNING = i18n.translate( } ); +export const RULE_STATUS_ENABLED = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusEnabled', + { + defaultMessage: 'Enabled', + } +); + +export const RULE_STATUS_DISABLED = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusDisabled', + { + defaultMessage: 'Disabled', + } +); + +export const RULE_STATUS_SNOOZED_INDEFINITELY = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusSnoozedIndefinitely', + { + defaultMessage: 'Snoozed indefinitely', + } +); + export const LAST_RESPONSE_COLUMN_TITLE = i18n.translate( 'xpack.observability.rules.rulesTable.columns.lastResponseTitle', { @@ -144,6 +165,13 @@ export const SEARCH_PLACEHOLDER = i18n.translate( { defaultMessage: 'Search' } ); +export const RULES_CHANGE_STATUS = i18n.translate( + 'xpack.observability.rules.rulesTable.changeStatusAriaLabel', + { + defaultMessage: 'Change status', + } +); + export const confirmModalText = ( numIdsToDelete: number, singleTitle: string, diff --git a/x-pack/plugins/observability/public/pages/rules/types.ts b/x-pack/plugins/observability/public/pages/rules/types.ts index 23443890ad8fa..1a15cf3d9cef2 100644 --- a/x-pack/plugins/observability/public/pages/rules/types.ts +++ b/x-pack/plugins/observability/public/pages/rules/types.ts @@ -4,17 +4,20 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { Dispatch, SetStateAction } from 'react'; import { EuiTableSortingType, EuiBasicTableColumn } from '@elastic/eui'; import { AlertExecutionStatus } from '../../../../alerting/common'; import { RuleTableItem, Rule } from '../../../../triggers_actions_ui/public'; export interface StatusProps { type: RuleStatus; + disabled: boolean; onClick: () => void; } export enum RuleStatus { enabled = 'enabled', disabled = 'disabled', + snoozed = 'snoozed', } export type Status = Record< @@ -27,10 +30,12 @@ export type Status = Record< export interface StatusContextProps { item: RuleTableItem; + disabled: boolean; onStatusChanged: (status: RuleStatus) => void; enableRule: (rule: Rule) => Promise; disableRule: (rule: Rule) => Promise; muteRule: (rule: Rule) => Promise; + unMuteRule: (rule: Rule) => Promise; } export interface StatusFilterProps { @@ -65,6 +70,7 @@ export interface FetchRulesProps { searchText: string | undefined; ruleLastResponseFilter: string[]; page: Pagination; + setPage: Dispatch>; sort: EuiTableSortingType['sort']; } diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 3d2505ed80513..9d483b63ac0a9 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -47,6 +47,7 @@ import { updateGlobalNavigation } from './update_global_navigation'; import { getExploratoryViewEmbeddable } from './components/shared/exploratory_view/embeddable'; import { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils'; import { createUseRulesLink } from './hooks/create_use_rules_link'; +import getAppDataView from './utils/observability_data_views/get_app_data_view'; export type ObservabilityPublicSetup = ReturnType; @@ -280,6 +281,7 @@ export class Plugin PageTemplate, }, createExploratoryViewUrl, + getAppDataView: getAppDataView(pluginsStart.dataViews), ExploratoryViewEmbeddable: getExploratoryViewEmbeddable(coreStart, pluginsStart), useRulesLink: createUseRulesLink(config.unsafe.rules.enabled), }; diff --git a/x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts b/x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts new file mode 100644 index 0000000000000..4b4b03412c0c7 --- /dev/null +++ b/x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.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 { AppDataType } from '../../components/shared/exploratory_view/types'; +import type { DataViewsPublicPluginStart } from '../../../../../../src/plugins/data_views/public'; + +const getAppDataView = (data: DataViewsPublicPluginStart) => { + return async (appId: AppDataType, indexPattern?: string) => { + try { + const { ObservabilityDataViews } = await import('./observability_data_views'); + + const obsvIndexP = new ObservabilityDataViews(data); + return await obsvIndexP.getDataView(appId, indexPattern); + } catch (e) { + return null; + } + }; +}; + +// eslint-disable-next-line import/no-default-export +export default getAppDataView; diff --git a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts index 8a74482bb14ca..86ce6cd587213 100644 --- a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts +++ b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts @@ -176,3 +176,6 @@ export class ObservabilityDataViews { } } } + +// eslint-disable-next-line import/no-default-export +export default ObservabilityDataViews; diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/add_integration.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts similarity index 97% rename from x-pack/plugins/osquery/cypress/integration/superuser/add_integration.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts index 4f9fb4304fd28..11a904526d314 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/add_integration.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts @@ -11,8 +11,9 @@ import { addIntegration } from '../../tasks/integrations'; import { login } from '../../tasks/login'; // import { findAndClickButton, findFormFieldByRowsLabelAndType } from '../../tasks/live_query'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; +import { DEFAULT_POLICY } from '../../screens/fleet'; -describe('Super User - Add Integration', () => { +describe('ALL - Add Integration', () => { const integration = 'Osquery Manager'; before(() => { runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); @@ -65,7 +66,7 @@ describe('Super User - Add Integration', () => { it('add integration', () => { cy.visit(FLEET_AGENT_POLICIES); - cy.contains('Default Fleet Server policy').click(); + cy.contains(DEFAULT_POLICY).click(); cy.contains('Add integration').click(); cy.contains(integration).click(); addIntegration(); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/alerts.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/alerts.spec.ts similarity index 100% rename from x-pack/plugins/osquery/cypress/integration/superuser/alerts.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/alerts.spec.ts diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts similarity index 96% rename from x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts index 5c21f29b650e7..46d927329aa98 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts @@ -9,7 +9,7 @@ import { navigateTo } from '../../tasks/navigation'; import { login } from '../../tasks/login'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; -describe('SuperUser - Delete ECS Mappings', () => { +describe('ALL - Delete ECS Mappings', () => { const SAVED_QUERY_ID = 'Saved-Query-Id'; before(() => { diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/live_query.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/live_query.spec.ts similarity index 73% rename from x-pack/plugins/osquery/cypress/integration/superuser/live_query.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/live_query.spec.ts index f979f793873f1..d6af17596d89a 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/live_query.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/live_query.spec.ts @@ -15,8 +15,10 @@ import { typeInECSFieldInput, typeInOsqueryFieldInput, } from '../../tasks/live_query'; +import { RESULTS_TABLE_CELL_WRRAPER } from '../../screens/live_query'; +import { getAdvancedButton } from '../../screens/integrations'; -describe('Super User - Live Query', () => { +describe('ALL - Live Query', () => { beforeEach(() => { login(); navigateTo('/app/osquery'); @@ -31,23 +33,25 @@ describe('Super User - Live Query', () => { // checking submit by clicking cmd+enter inputQuery(cmd); checkResults(); - cy.react('EuiDataGridHeaderCellWrapper', { + cy.contains('View in Discover').should('exist'); + cy.contains('View in Lens').should('exist'); + cy.react(RESULTS_TABLE_CELL_WRRAPER, { props: { id: 'osquery.days.number', index: 1 }, }); - cy.react('EuiDataGridHeaderCellWrapper', { + cy.react(RESULTS_TABLE_CELL_WRRAPER, { props: { id: 'osquery.hours.number', index: 2 }, }); - cy.react('EuiAccordion', { props: { buttonContent: 'Advanced' } }).click(); + getAdvancedButton().click(); typeInECSFieldInput('message{downArrow}{enter}'); typeInOsqueryFieldInput('days{downArrow}{enter}'); submitQuery(); checkResults(); - cy.react('EuiDataGridHeaderCellWrapper', { + cy.react(RESULTS_TABLE_CELL_WRRAPER, { props: { id: 'message', index: 1 }, }); - cy.react('EuiDataGridHeaderCellWrapper', { + cy.react(RESULTS_TABLE_CELL_WRRAPER, { props: { id: 'osquery.days.number', index: 2 }, }).react('EuiIconIndexMapping'); }); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/metrics.spec.ts similarity index 97% rename from x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/metrics.spec.ts index f64e6b31ae7a5..ba71e75d9ea7b 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/metrics.spec.ts @@ -10,7 +10,7 @@ import { login } from '../../tasks/login'; import { checkResults, inputQuery, submitQuery } from '../../tasks/live_query'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; -describe('Super User - Metrics', () => { +describe('ALL - Metrics', () => { beforeEach(() => { login(); navigateTo('/app/osquery'); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/packs.spec.ts similarity index 90% rename from x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/packs.spec.ts index fd04d0a62b160..eafe36874244e 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/packs.spec.ts @@ -16,8 +16,10 @@ import { login } from '../../tasks/login'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; import { preparePack } from '../../tasks/packs'; import { addIntegration, closeModalIfVisible } from '../../tasks/integrations'; +import { DEFAULT_POLICY } from '../../screens/fleet'; +import { getSavedQueriesDropdown } from '../../screens/live_query'; -describe('SuperUser - Packs', () => { +describe('ALL - Packs', () => { const integration = 'Osquery Manager'; const SAVED_QUERY_ID = 'Saved-Query-Id'; const PACK_NAME = 'Pack-name'; @@ -47,21 +49,15 @@ describe('SuperUser - Packs', () => { findAndClickButton('Add pack'); findFormFieldByRowsLabelAndType('Name', PACK_NAME); findFormFieldByRowsLabelAndType('Description (optional)', 'Pack description'); - findFormFieldByRowsLabelAndType( - 'Scheduled agent policies (optional)', - 'Default Fleet Server policy' - ); + findFormFieldByRowsLabelAndType('Scheduled agent policies (optional)', DEFAULT_POLICY); cy.react('List').first().click(); findAndClickButton('Add query'); cy.contains('Attach next query'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type(SAVED_QUERY_ID); - cy.react('List').first().click(); + getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow}{enter}`); cy.react('EuiFormRow', { props: { label: 'Interval (s)' } }) .click() .clear() - .type('10'); + .type('500'); cy.react('EuiFlyoutFooter').react('EuiButton').contains('Save').click(); cy.react('EuiTableRow').contains(SAVED_QUERY_ID); findAndClickButton('Save pack'); @@ -94,10 +90,7 @@ describe('SuperUser - Packs', () => { findAndClickButton('Add query'); cy.contains('Attach next query'); cy.contains('ID must be unique').should('not.exist'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type(SAVED_QUERY_ID); - cy.react('List').first().click(); + getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow}{enter}`); cy.contains('ID must be unique').should('exist'); cy.react('EuiFlyoutFooter').react('EuiButtonEmpty').contains('Cancel').click(); }); @@ -175,9 +168,7 @@ describe('SuperUser - Packs', () => { findAndClickButton('Add query'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type('Multiple {downArrow} {enter}'); + getSavedQueriesDropdown().click().type('Multiple {downArrow} {enter}'); cy.contains('Custom key/value pairs'); cy.contains('Days of uptime'); cy.contains('List of keywords used to tag each'); @@ -185,9 +176,7 @@ describe('SuperUser - Packs', () => { cy.contains('Client network address.'); cy.contains('Total uptime seconds'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type('NOMAPPING {downArrow} {enter}'); + getSavedQueriesDropdown().click().type('NOMAPPING {downArrow} {enter}'); cy.contains('Custom key/value pairs').should('not.exist'); cy.contains('Days of uptime').should('not.exist'); cy.contains('List of keywords used to tag each').should('not.exist'); @@ -195,9 +184,7 @@ describe('SuperUser - Packs', () => { cy.contains('Client network address.').should('not.exist'); cy.contains('Total uptime seconds').should('not.exist'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type('ONE_MAPPING {downArrow} {enter}'); + getSavedQueriesDropdown().click().type('ONE_MAPPING {downArrow} {enter}'); cy.contains('Name of the continent'); cy.contains('Seconds of uptime'); diff --git a/x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts new file mode 100644 index 0000000000000..4e48e819ac0ab --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { navigateTo } from '../../tasks/navigation'; + +import { login } from '../../tasks/login'; +import { getSavedQueriesComplexTest } from '../../tasks/saved_queries'; + +const SAVED_QUERY_ID = 'Saved-Query-Id'; +const SAVED_QUERY_DESCRIPTION = 'Test saved query description'; + +describe('ALL - Saved queries', () => { + beforeEach(() => { + login(); + navigateTo('/app/osquery'); + }); + + getSavedQueriesComplexTest(SAVED_QUERY_ID, SAVED_QUERY_DESCRIPTION); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts new file mode 100644 index 0000000000000..d3a00f970322b --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts @@ -0,0 +1,80 @@ +/* + * 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 { login } from '../../tasks/login'; +import { navigateTo } from '../../tasks/navigation'; +import { ROLES } from '../../test'; +import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; + +describe('Reader - only READ', () => { + const SAVED_QUERY_ID = 'Saved-Query-Id'; + + beforeEach(() => { + login(ROLES.reader); + }); + before(() => { + runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); + }); + + after(() => { + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); + }); + + it('should not be able to add nor run saved queries', () => { + navigateTo('/app/osquery/saved_queries'); + cy.waitForReact(1000); + cy.contains(SAVED_QUERY_ID); + cy.contains('Add saved query').should('be.disabled'); + cy.react('PlayButtonComponent', { + props: { savedQuery: { attributes: { id: SAVED_QUERY_ID } } }, + options: { timeout: 3000 }, + }).should('not.exist'); + cy.react('CustomItemAction', { + props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + }).click(); + cy.react('EuiFormRow', { props: { label: 'ID' } }) + .getBySel('input') + .should('be.disabled'); + cy.react('EuiFormRow', { props: { label: 'Description (optional)' } }) + .getBySel('input') + .should('be.disabled'); + + cy.contains('Update query').should('not.exist'); + cy.contains(`Delete query`).should('not.exist'); + }); + it('should not be able to play in live queries history', () => { + navigateTo('/app/osquery/live_queries'); + cy.waitForReact(1000); + cy.contains('New live query').should('be.disabled'); + cy.contains('select * from uptime'); + cy.react('EuiIconPlay', { options: { timeout: 3000 } }).should('not.exist'); + cy.react('ActionTableResultsButton').should('exist'); + }); + it('should not be able to add nor edit packs', () => { + const PACK_NAME = 'removing-pack'; + + navigateTo('/app/osquery/packs'); + cy.waitForReact(1000); + cy.contains('Add pack').should('be.disabled'); + cy.react('ActiveStateSwitchComponent', { + props: { item: { attributes: { name: PACK_NAME } } }, + }) + .find('button') + .should('be.disabled'); + cy.contains(PACK_NAME).click(); + cy.contains(`${PACK_NAME} details`); + cy.contains('Edit').should('be.disabled'); + cy.react('CustomItemAction', { + props: { index: 0, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + cy.react('CustomItemAction', { + props: { index: 1, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + }); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts new file mode 100644 index 0000000000000..64d72c92dda04 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.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 { login } from '../../tasks/login'; +import { navigateTo } from '../../tasks/navigation'; +import { ROLES } from '../../test'; +import { checkResults, selectAllAgents, submitQuery } from '../../tasks/live_query'; +import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; +import { getSavedQueriesDropdown, LIVE_QUERY_EDITOR } from '../../screens/live_query'; + +describe('T1 Analyst - READ + runSavedQueries ', () => { + const SAVED_QUERY_ID = 'Saved-Query-Id'; + + beforeEach(() => { + login(ROLES.t1_analyst); + }); + before(() => { + runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); + }); + + after(() => { + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); + }); + + it('should be able to run saved queries but not add new ones', () => { + navigateTo('/app/osquery/saved_queries'); + cy.waitForReact(1000); + cy.contains(SAVED_QUERY_ID); + cy.contains('Add saved query').should('be.disabled'); + cy.react('PlayButtonComponent', { + props: { savedQuery: { attributes: { id: SAVED_QUERY_ID } } }, + }) + .should('not.be.disabled') + .click(); + selectAllAgents(); + cy.contains('select * from uptime;'); + submitQuery(); + checkResults(); + cy.contains('View in Discover').should('not.exist'); + cy.contains('View in Lens').should('not.exist'); + }); + it('should be able to play in live queries history', () => { + navigateTo('/app/osquery/live_queries'); + cy.waitForReact(1000); + cy.contains('New live query').should('not.be.disabled'); + cy.contains('select * from uptime'); + cy.wait(1000); + cy.react('EuiTableBody').first().react('DefaultItemAction').first().click(); + selectAllAgents(); + cy.contains(SAVED_QUERY_ID); + submitQuery(); + checkResults(); + }); + it('should be able to use saved query in a new query', () => { + navigateTo('/app/osquery/live_queries'); + cy.waitForReact(1000); + cy.contains('New live query').should('not.be.disabled').click(); + selectAllAgents(); + getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow} {enter}`); + cy.contains('select * from uptime'); + submitQuery(); + checkResults(); + }); + it('should not be able to add nor edit packs', () => { + const PACK_NAME = 'removing-pack'; + + navigateTo('/app/osquery/packs'); + cy.waitForReact(1000); + cy.contains('Add pack').should('be.disabled'); + cy.react('ActiveStateSwitchComponent', { + props: { item: { attributes: { name: PACK_NAME } } }, + }) + .find('button') + .should('be.disabled'); + cy.contains(PACK_NAME).click(); + cy.contains(`${PACK_NAME} details`); + cy.contains('Edit').should('be.disabled'); + cy.react('CustomItemAction', { + props: { index: 0, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + cy.react('CustomItemAction', { + props: { index: 1, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + }); + it('should not be able to create new liveQuery from scratch', () => { + navigateTo('/app/osquery'); + + cy.contains('New live query').click(); + selectAllAgents(); + cy.get(LIVE_QUERY_EDITOR).should('not.exist'); + cy.contains('Submit').should('be.disabled'); + }); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts new file mode 100644 index 0000000000000..805eb134a44f5 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts @@ -0,0 +1,113 @@ +/* + * 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 { login } from '../../tasks/login'; +import { navigateTo } from '../../tasks/navigation'; +import { ROLES } from '../../test'; +import { + checkResults, + selectAllAgents, + submitQuery, + inputQuery, + typeInECSFieldInput, + typeInOsqueryFieldInput, +} from '../../tasks/live_query'; +import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; +import { getSavedQueriesComplexTest } from '../../tasks/saved_queries'; + +describe('T2 Analyst - READ + Write Live/Saved + runSavedQueries ', () => { + const SAVED_QUERY_ID = 'Saved-Query-Id'; + const NEW_SAVED_QUERY_ID = 'Saved-Query-Id-T2'; + const NEW_SAVED_QUERY_DESCRIPTION = 'Test saved query description T2'; + beforeEach(() => { + login(ROLES.t2_analyst); + navigateTo('/app/osquery'); + }); + before(() => { + runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); + }); + + after(() => { + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); + }); + + getSavedQueriesComplexTest(NEW_SAVED_QUERY_ID, NEW_SAVED_QUERY_DESCRIPTION); + + it('should not be able to add nor edit packs', () => { + const PACK_NAME = 'removing-pack'; + + navigateTo('/app/osquery/packs'); + cy.waitForReact(1000); + cy.contains('Add pack').should('be.disabled'); + cy.react('ActiveStateSwitchComponent', { + props: { item: { attributes: { name: PACK_NAME } } }, + }) + .find('button') + .should('be.disabled'); + cy.contains(PACK_NAME).click(); + cy.contains(`${PACK_NAME} details`); + cy.contains('Edit').should('be.disabled'); + cy.react('CustomItemAction', { + props: { index: 0, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + cy.react('CustomItemAction', { + props: { index: 1, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + }); + + it('should run query and enable ecs mapping', () => { + const cmd = Cypress.platform === 'darwin' ? '{meta}{enter}' : '{ctrl}{enter}'; + cy.contains('New live query').click(); + selectAllAgents(); + inputQuery('select * from uptime; '); + cy.wait(500); + // checking submit by clicking cmd+enter + inputQuery(cmd); + checkResults(); + cy.contains('View in Discover').should('not.exist'); + cy.contains('View in Lens').should('not.exist'); + cy.react('EuiDataGridHeaderCellWrapper', { + props: { id: 'osquery.days.number', index: 1 }, + }); + cy.react('EuiDataGridHeaderCellWrapper', { + props: { id: 'osquery.hours.number', index: 2 }, + }); + + cy.react('EuiAccordion', { props: { buttonContent: 'Advanced' } }).click(); + typeInECSFieldInput('message{downArrow}{enter}'); + typeInOsqueryFieldInput('days{downArrow}{enter}'); + submitQuery(); + + checkResults(); + cy.react('EuiDataGridHeaderCellWrapper', { + props: { id: 'message', index: 1 }, + }); + cy.react('EuiDataGridHeaderCellWrapper', { + props: { id: 'osquery.days.number', index: 2 }, + }).react('EuiIconIndexMapping'); + }); + it('to click the edit button and edit pack', () => { + navigateTo('/app/osquery/saved_queries'); + + cy.react('CustomItemAction', { + props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + }).click(); + cy.contains('Custom key/value pairs.').should('exist'); + cy.contains('Hours of uptime').should('exist'); + cy.react('EuiButtonIcon', { props: { id: 'labels-trash' } }).click(); + cy.react('EuiButton').contains('Update query').click(); + cy.wait(5000); + + cy.react('CustomItemAction', { + props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + }).click(); + cy.contains('Custom key/value pairs').should('not.exist'); + cy.contains('Hours of uptime').should('not.exist'); + }); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts b/x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts deleted file mode 100644 index 11c78560d25fe..0000000000000 --- a/x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts +++ /dev/null @@ -1,30 +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 { login } from '../../tasks/login'; -import { navigateTo } from '../../tasks/navigation'; -import { ROLES } from '../../test'; -import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; - -describe('T1 Analyst - Live Query', () => { - beforeEach(() => { - login(ROLES.t1_analyst); - }); - - describe('should run a live query', () => { - before(() => { - runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); - }); - after(() => { - runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); - }); - it('when passed as a saved query ', () => { - navigateTo('/app/osquery/saved_queries'); - cy.waitForReact(1000); - }); - }); -}); diff --git a/x-pack/plugins/osquery/cypress/screens/fleet.ts b/x-pack/plugins/osquery/cypress/screens/fleet.ts index 6be51e5ed24bc..b7cce6484c405 100644 --- a/x-pack/plugins/osquery/cypress/screens/fleet.ts +++ b/x-pack/plugins/osquery/cypress/screens/fleet.ts @@ -9,3 +9,4 @@ export const ADD_AGENT_BUTTON = 'addAgentButton'; export const AGENT_POLICIES_TAB = 'fleet-agent-policies-tab'; export const ENROLLMENT_TOKENS_TAB = 'fleet-enrollment-tokens-tab'; +export const DEFAULT_POLICY = 'Default Fleet Server policy'; diff --git a/x-pack/plugins/osquery/cypress/screens/integrations.ts b/x-pack/plugins/osquery/cypress/screens/integrations.ts index 42c22096cea96..b02efb9cff512 100644 --- a/x-pack/plugins/osquery/cypress/screens/integrations.ts +++ b/x-pack/plugins/osquery/cypress/screens/integrations.ts @@ -24,3 +24,6 @@ export const LATEST_VERSION = 'latestVersion'; export const PACKAGE_VERSION = 'packageVersionText'; export const SAVE_PACKAGE_CONFIRM = '[data-test-subj=confirmModalConfirmButton]'; + +export const getAdvancedButton = () => + cy.react('EuiAccordion', { props: { buttonContent: 'Advanced' } }); diff --git a/x-pack/plugins/osquery/cypress/screens/live_query.ts b/x-pack/plugins/osquery/cypress/screens/live_query.ts index cba4a35c05719..54c19fe508705 100644 --- a/x-pack/plugins/osquery/cypress/screens/live_query.ts +++ b/x-pack/plugins/osquery/cypress/screens/live_query.ts @@ -9,4 +9,8 @@ export const AGENT_FIELD = '[data-test-subj="comboBoxInput"]'; export const ALL_AGENTS_OPTION = '[title="All agents"]'; export const LIVE_QUERY_EDITOR = '#osquery_editor'; export const SUBMIT_BUTTON = '#submit-button'; + export const RESULTS_TABLE_BUTTON = 'dataGridFullScreenButton'; +export const RESULTS_TABLE_CELL_WRRAPER = 'EuiDataGridHeaderCellWrapper'; +export const getSavedQueriesDropdown = () => + cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts b/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts similarity index 76% rename from x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts rename to x-pack/plugins/osquery/cypress/tasks/saved_queries.ts index bc8417d5facf5..bfa7b51643382 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts +++ b/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { navigateTo } from '../../tasks/navigation'; -import { RESULTS_TABLE_BUTTON } from '../../screens/live_query'; +import { RESULTS_TABLE_BUTTON } from '../screens/live_query'; import { checkResults, BIG_QUERY, @@ -15,18 +14,9 @@ import { inputQuery, selectAllAgents, submitQuery, -} from '../../tasks/live_query'; -import { login } from '../../tasks/login'; - -describe('Super User - Saved queries', () => { - const SAVED_QUERY_ID = 'Saved-Query-Id'; - const SAVED_QUERY_DESCRIPTION = 'Saved Query Description'; - - beforeEach(() => { - login(); - navigateTo('/app/osquery'); - }); +} from './live_query'; +export const getSavedQueriesComplexTest = (savedQueryId: string, savedQueryDescription: string) => it( 'should create a new query and verify: \n ' + '- hidden columns, full screen and sorting \n' + @@ -78,8 +68,8 @@ describe('Super User - Saved queries', () => { cy.contains('Exit full screen').should('not.exist'); cy.contains('Save for later').click(); cy.contains('Save query'); - findFormFieldByRowsLabelAndType('ID', SAVED_QUERY_ID); - findFormFieldByRowsLabelAndType('Description (optional)', SAVED_QUERY_DESCRIPTION); + findFormFieldByRowsLabelAndType('ID', savedQueryId); + findFormFieldByRowsLabelAndType('Description (optional)', savedQueryDescription); cy.react('EuiButtonDisplay').contains('Save').click(); // visit Status results @@ -89,31 +79,30 @@ describe('Super User - Saved queries', () => { // play saved query cy.contains('Saved queries').click(); - cy.contains(SAVED_QUERY_ID); + cy.contains(savedQueryId); cy.react('PlayButtonComponent', { - props: { savedQuery: { attributes: { id: SAVED_QUERY_ID } } }, + props: { savedQuery: { attributes: { id: savedQueryId } } }, }).click(); selectAllAgents(); submitQuery(); // edit saved query cy.contains('Saved queries').click(); - cy.contains(SAVED_QUERY_ID); + cy.contains(savedQueryId); cy.react('CustomItemAction', { - props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + props: { index: 1, item: { attributes: { id: savedQueryId } } }, }).click(); findFormFieldByRowsLabelAndType('Description (optional)', ' Edited'); cy.react('EuiButton').contains('Update query').click(); - cy.contains(`${SAVED_QUERY_DESCRIPTION} Edited`); + cy.contains(`${savedQueryDescription} Edited`); // delete saved query - cy.contains(SAVED_QUERY_ID); + cy.contains(savedQueryId); cy.react('CustomItemAction', { - props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + props: { index: 1, item: { attributes: { id: savedQueryId } } }, }).click(); deleteAndConfirm('query'); - cy.contains(SAVED_QUERY_ID).should('exist'); - cy.contains(SAVED_QUERY_ID).should('not.exist'); + cy.contains(savedQueryId).should('exist'); + cy.contains(savedQueryId).should('not.exist'); } ); -}); diff --git a/x-pack/plugins/osquery/public/actions/actions_table.tsx b/x-pack/plugins/osquery/public/actions/actions_table.tsx index d92d9ee117fde..2f81394bccde8 100644 --- a/x-pack/plugins/osquery/public/actions/actions_table.tsx +++ b/x-pack/plugins/osquery/public/actions/actions_table.tsx @@ -13,7 +13,7 @@ import { useHistory } from 'react-router-dom'; import { useAllActions } from './use_all_actions'; import { Direction } from '../../common/search_strategy'; -import { useRouterNavigate } from '../common/lib/kibana'; +import { useRouterNavigate, useKibana } from '../common/lib/kibana'; interface ActionTableResultsButtonProps { actionId: string; @@ -28,6 +28,7 @@ const ActionTableResultsButton: React.FC = ({ act ActionTableResultsButton.displayName = 'ActionTableResultsButton'; const ActionsTableComponent = () => { + const permissions = useKibana().services.application.capabilities.osquery; const { push } = useHistory(); const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(20); @@ -84,6 +85,10 @@ const ActionsTableComponent = () => { }), [push] ); + const isPlayButtonAvailable = useCallback( + () => permissions.runSavedQueries || permissions.writeLiveQueries, + [permissions.runSavedQueries, permissions.writeLiveQueries] + ); const columns = useMemo( () => [ @@ -128,6 +133,7 @@ const ActionsTableComponent = () => { type: 'icon', icon: 'play', onClick: handlePlayClick, + available: isPlayButtonAvailable, }, { render: renderActionsColumn, @@ -137,6 +143,7 @@ const ActionsTableComponent = () => { ], [ handlePlayClick, + isPlayButtonAvailable, renderActionsColumn, renderAgentsColumn, renderCreatedByColumn, diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx index c3770f202c087..0f5caca5d19bd 100644 --- a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx @@ -6,12 +6,13 @@ */ import { EuiLoadingContent } from '@elastic/eui'; -import React, { useEffect } from 'react'; +import React from 'react'; import { PackageCustomExtensionComponentProps } from '../../../fleet/public'; import { NavigationButtons } from './navigation_buttons'; import { DisabledCallout } from './disabled_callout'; -import { useKibana } from '../common/lib/kibana'; +import { MissingPrivileges } from '../routes/components/missing_privileges'; +import { useFetchStatus } from './use_fetch_status'; /** * Exports Osquery-specific package policy instructions @@ -19,22 +20,16 @@ import { useKibana } from '../common/lib/kibana'; */ export const OsqueryManagedCustomButtonExtension = React.memo( () => { - const [disabled, setDisabled] = React.useState(null); - const { http } = useKibana().services; + const { loading, disabled, permissionDenied } = useFetchStatus(); - useEffect(() => { - const fetchStatus = () => { - http.get<{ install_status: string }>('/internal/osquery/status').then((response) => { - setDisabled(response?.install_status !== 'installed'); - }); - }; - fetchStatus(); - }, [http]); - - if (disabled === null) { + if (loading) { return ; } + if (permissionDenied) { + return ; + } + return ( <> {disabled ? : null} diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx index 1b7b87fe180bf..aaedec1e0dbe1 100644 --- a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx @@ -46,6 +46,7 @@ import { fieldValidators, ValidationFunc, } from '../shared_imports'; +import { useFetchStatus } from './use_fetch_status'; // https://github.com/elastic/beats/blob/master/x-pack/osquerybeat/internal/osqd/args.go#L57 const RESTRICTED_CONFIG_OPTIONS = [ @@ -340,6 +341,8 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo< // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const { permissionDenied } = useFetchStatus(); + return ( <> {!editMode ? : null} @@ -366,23 +369,27 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo< ) : null} - - - - -
- - - -
+ {!permissionDenied && ( + <> + + + + +
+ + + +
+ + )} ); }); diff --git a/x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx b/x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx new file mode 100644 index 0000000000000..3f86675f8be41 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect } from 'react'; +import { useKibana } from '../common/lib/kibana'; + +export const useFetchStatus = () => { + const [loading, setLoading] = useState(true); + const [disabled, setDisabled] = useState(false); + const [permissionDenied, setPermissionDenied] = useState(false); + const { http } = useKibana().services; + + useEffect(() => { + const fetchStatus = () => { + http + .get<{ install_status: string }>('/internal/osquery/status') + .then((response) => { + setLoading(false); + setDisabled(response?.install_status !== 'installed'); + }) + .catch((err) => { + setLoading(false); + if (err.body.statusCode === 403) { + setPermissionDenied(true); + } + }); + }; + fetchStatus(); + }, [http]); + + return { loading, disabled, permissionDenied }; +}; diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index bd8e2bf42129f..9164266d6a8c5 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -273,16 +273,26 @@ const LiveQueryFormComponent: React.FC = ({ [permissions.writeSavedQueries] ); + const isSavedQueryDisabled = useMemo( + () => + queryComponentProps.disabled || !permissions.runSavedQueries || !permissions.readSavedQueries, + [permissions.readSavedQueries, permissions.runSavedQueries, queryComponentProps.disabled] + ); + const queryFieldStepContent = useMemo( () => ( <> {queryField ? ( <> - - + {!isSavedQueryDisabled && ( + <> + + + + )} = ({ [ queryField, queryComponentProps, - permissions.runSavedQueries, permissions.writeSavedQueries, handleSavedQueryChange, ecsMappingField, @@ -372,6 +381,7 @@ const LiveQueryFormComponent: React.FC = ({ enabled, isSubmitting, submit, + isSavedQueryDisabled, ] ); 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 836350d12d43e..c99662804b1e2 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 @@ -207,6 +207,7 @@ const ViewResultsInLensActionComponent: React.FC { const lensService = useKibana().services.lens; + const isLensAvailable = lensService?.canUseEditor(); const handleClick = useCallback( (event) => { @@ -230,14 +231,12 @@ const ViewResultsInLensActionComponent: React.FC + {VIEW_IN_LENS} ); @@ -247,7 +246,7 @@ const ViewResultsInLensActionComponent: React.FC @@ -264,7 +263,10 @@ const ViewResultsInDiscoverActionComponent: React.FC { - const locator = useKibana().services.discover?.locator; + const { discover, application } = useKibana().services; + const locator = discover?.locator; + const discoverPermissions = application.capabilities.discover; + const [discoverUrl, setDiscoverUrl] = useState(''); useEffect(() => { @@ -336,6 +338,9 @@ const ViewResultsInDiscoverActionComponent: React.FC diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx index f16e32a62cb4f..d019b831d96f5 100644 --- a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx +++ b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx @@ -125,12 +125,12 @@ const SavedQueriesPageComponent = () => { ); const renderPlayAction = useCallback( - (item: SavedQuerySO) => ( - - ), + (item: SavedQuerySO) => + permissions.runSavedQueries || permissions.writeLiveQueries ? ( + + ) : ( + <> + ), [permissions.runSavedQueries, permissions.writeLiveQueries] ); diff --git a/x-pack/plugins/osquery/scripts/roles_users/README.md b/x-pack/plugins/osquery/scripts/roles_users/README.md index d0a28049c865b..aadc696a5f504 100644 --- a/x-pack/plugins/osquery/scripts/roles_users/README.md +++ b/x-pack/plugins/osquery/scripts/roles_users/README.md @@ -4,7 +4,7 @@ Initial version of roles support for Osquery |:--------------------------------------------:|:-----------------------------------------------:|:-------------------------------:|:-------------:|:-----:|:-------------------:|:-----:|:-------------:|:----------:| | NO Data Source access user | none | none | none | none | none | none | none | none | | Reader (read-only user) | read | read | read | read | none | none | none | none | -| T1 Analyst | read | read, write (run saved queries) | read | read | none | none | none | none | +| T1 Analyst | read | read, (run saved queries) | read | read | none | none | none | none | | T2 Analyst | read | read, write (tbc) | all | read | none | read | none | none | | Hunter / T3 Analyst | read | all | all | all | none | all | read | all | | SOC Manager | read | all | all | all | none | all | read | all | diff --git a/x-pack/plugins/osquery/scripts/roles_users/index.ts b/x-pack/plugins/osquery/scripts/roles_users/index.ts index 1f51d8691a715..ce29ba92e2590 100644 --- a/x-pack/plugins/osquery/scripts/roles_users/index.ts +++ b/x-pack/plugins/osquery/scripts/roles_users/index.ts @@ -5,4 +5,6 @@ * 2.0. */ +export * from './reader'; export * from './t1_analyst'; +export * from './t2_analyst'; diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh new file mode 100755 index 0000000000000..57704f7abf0d3 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh @@ -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. +# + +curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XDELETE ${ELASTICSEARCH_URL}/_security/user/reader diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh new file mode 100755 index 0000000000000..37db6e10ced55 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh @@ -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. +# + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XGET ${KIBANA_URL}/api/security/role/reader | jq -S . diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx b/x-pack/plugins/osquery/scripts/roles_users/reader/index.ts similarity index 61% rename from x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx rename to x-pack/plugins/osquery/scripts/roles_users/reader/index.ts index 550cefcf13e92..6fbd33c69b3a6 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/index.ts @@ -5,8 +5,7 @@ * 2.0. */ -export * from './dns'; -export * from './network_events'; -export * from './tls_handshakes'; -export * from './unique_flows'; -export * from './unique_private_ips'; +import * as readerUser from './user.json'; +import * as readerRole from './role.json'; + +export { readerUser, readerRole }; diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh new file mode 100755 index 0000000000000..338783465f993 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh @@ -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. +# + +ROLE_CONFIG=(${@:-./detections_role.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XPUT ${KIBANA_URL}/api/security/role/reader \ +-d @${ROLE_CONFIG} diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh new file mode 100755 index 0000000000000..8a93326a820b7 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh @@ -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. +# + +USER=(${@:-./detections_user.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + ${ELASTICSEARCH_URL}/_security/user/reader \ +-d @${USER} diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/role.json b/x-pack/plugins/osquery/scripts/roles_users/reader/role.json new file mode 100644 index 0000000000000..85c2ff52f84d6 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/role.json @@ -0,0 +1,19 @@ +{ + "elasticsearch": { + "indices": [ + { + "names": ["logs-osquery_manager*"], + "privileges": ["read"] + } + ] + }, + "kibana": [ + { + "feature": { + "osquery": ["read"] + }, + "spaces": ["*"] + } + ] +} + diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/user.json b/x-pack/plugins/osquery/scripts/roles_users/reader/user.json new file mode 100644 index 0000000000000..a6c3c38cdd16e --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["reader"], + "full_name": "Reader", + "email": "osquery@example.com" +} diff --git a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json index 85c2ff52f84d6..12d5c2607f9ab 100644 --- a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json +++ b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json @@ -10,7 +10,7 @@ "kibana": [ { "feature": { - "osquery": ["read"] + "osquery": ["read", "run_saved_queries" ] }, "spaces": ["*"] } diff --git a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json index 203abec8ad433..cef1935d57068 100644 --- a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json +++ b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json @@ -2,5 +2,5 @@ "password": "changeme", "roles": ["t1_analyst"], "full_name": "T1 Analyst", - "email": "detections-reader@example.com" + "email": "osquery@example.com" } diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh new file mode 100755 index 0000000000000..6dccb0d8c6067 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh @@ -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. +# + +curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XDELETE ${ELASTICSEARCH_URL}/_security/user/t2_analyst diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh new file mode 100755 index 0000000000000..ce9149d8b9fc7 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh @@ -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. +# + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XGET ${KIBANA_URL}/api/security/role/t2_analyst | jq -S . diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts new file mode 100644 index 0000000000000..a3a8357e67c7f --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t2AnalystUser from './user.json'; +import * as t2AnalystRole from './role.json'; + +export { t2AnalystUser, t2AnalystRole }; diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh new file mode 100755 index 0000000000000..b94c738c3e3db --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh @@ -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. +# + +ROLE_CONFIG=(${@:-./detections_role.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XPUT ${KIBANA_URL}/api/security/role/t2_analyst \ +-d @${ROLE_CONFIG} diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh new file mode 100755 index 0000000000000..3a901490515af --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh @@ -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. +# + +USER=(${@:-./detections_user.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + ${ELASTICSEARCH_URL}/_security/user/t2_analyst \ +-d @${USER} diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json new file mode 100644 index 0000000000000..43133a62ec56b --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json @@ -0,0 +1,19 @@ +{ + "elasticsearch": { + "indices": [ + { + "names": ["logs-osquery_manager*"], + "privileges": ["read"] + } + ] + }, + "kibana": [ + { + "feature": { + "osquery": ["read", "live_queries_all", "saved_queries_all", "packs_read", "run_saved_queries"] + }, + "spaces": ["*"] + } + ] +} + diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json new file mode 100644 index 0000000000000..36096b2cc8f06 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["t2_analyst"], + "full_name": "T2 Analyst", + "email": "osquery@example.com" +} diff --git a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts index 37c08d712e3f6..b37e6032331dd 100644 --- a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts +++ b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts @@ -9,7 +9,6 @@ import { pickBy, isEmpty } from 'lodash'; import uuid from 'uuid'; import moment from 'moment-timezone'; -import { PLUGIN_ID } from '../../../common'; import { IRouter } from '../../../../../../src/core/server'; import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; @@ -22,6 +21,7 @@ import { import { incrementCount } from '../usage'; import { getInternalSavedObjectsClient } from '../../usage/collector'; +import { savedQuerySavedObjectType } from '../../../common/types'; export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.post( @@ -33,15 +33,38 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon CreateActionRequestBodySchema >(createActionRequestBodySchema), }, - options: { - tags: [`access:${PLUGIN_ID}-readLiveQueries`, `access:${PLUGIN_ID}-runSavedQueries`], - }, }, async (context, request, response) => { const esClient = context.core.elasticsearch.client.asInternalUser; + const soClient = context.core.savedObjects.client; const internalSavedObjectsClient = await getInternalSavedObjectsClient( osqueryContext.getStartServices ); + const [coreStartServices] = await osqueryContext.getStartServices(); + let savedQueryId = request.body.saved_query_id; + + const { + osquery: { writeLiveQueries, runSavedQueries }, + } = await coreStartServices.capabilities.resolveCapabilities(request); + + const isInvalid = !(writeLiveQueries || (runSavedQueries && request.body.saved_query_id)); + + if (isInvalid) { + return response.forbidden(); + } + + if (request.body.saved_query_id && runSavedQueries) { + const savedQueries = await soClient.find({ + type: savedQuerySavedObjectType, + }); + const actualSavedQuery = savedQueries.saved_objects.find( + (savedQuery) => savedQuery.id === request.body.saved_query_id + ); + + if (actualSavedQuery) { + savedQueryId = actualSavedQuery.id; + } + } const { agentSelection } = request.body as { agentSelection: AgentSelection }; const selectedAgents = await parseAgentSelection( @@ -55,8 +78,6 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon return response.badRequest({ body: new Error('No agents found for selection') }); } - // TODO: Add check for `runSavedQueries` only - try { const currentUser = await osqueryContext.security.authc.getCurrentUser(request)?.username; const action = { @@ -71,7 +92,7 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon { id: uuid.v4(), query: request.body.query, - saved_query_id: request.body.saved_query_id, + saved_query_id: savedQueryId, ecs_mapping: request.body.ecs_mapping, }, (value) => !isEmpty(value) diff --git a/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts new file mode 100644 index 0000000000000..7dc0f51f15f08 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts @@ -0,0 +1,34 @@ +/* + * 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 { getIsKibanaRequest } from './get_is_kibana_request'; + +describe('getIsKibanaRequest', () => { + it('should ensure the request has a kbn version and referer', () => { + expect( + getIsKibanaRequest({ + 'kbn-version': 'foo', + referer: 'somwhere', + }) + ).toBe(true); + }); + + it('should return false if the kbn version is missing', () => { + expect( + getIsKibanaRequest({ + referer: 'somwhere', + }) + ).toBe(false); + }); + + it('should return false if the referer is missing', () => { + expect( + getIsKibanaRequest({ + 'kbn-version': 'foo', + }) + ).toBe(false); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts new file mode 100644 index 0000000000000..c0961b84c7c28 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { Headers } from 'kibana/server'; + +/** + * Taken from + * https://github.com/elastic/kibana/blob/ec30f2aeeb10fb64b507935e558832d3ef5abfaa/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts#L113-L118 + */ +export const getIsKibanaRequest = (headers?: Headers): boolean => { + // The presence of these two request headers gives us a good indication that this is a first-party request from the Kibana client. + // We can't be 100% certain, but this is a reasonable attempt. + return !!(headers && headers['kbn-version'] && headers.referer); +}; diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts index 292e987879d58..df32abcc80865 100644 --- a/x-pack/plugins/rule_registry/server/plugin.ts +++ b/x-pack/plugins/rule_registry/server/plugin.ts @@ -29,7 +29,7 @@ import { AlertsClientFactory } from './alert_data_client/alerts_client_factory'; import { AlertsClient } from './alert_data_client/alerts_client'; import { RacApiRequestHandlerContext, RacRequestHandlerContext } from './types'; import { defineRoutes } from './routes'; -import { ruleRegistrySearchStrategyProvider } from './search_strategy'; +import { ruleRegistrySearchStrategyProvider, RULE_SEARCH_STRATEGY_NAME } from './search_strategy'; export interface RuleRegistryPluginSetupDependencies { security?: SecurityPluginSetup; @@ -115,7 +115,7 @@ export class RuleRegistryPlugin ); plugins.data.search.registerSearchStrategy( - 'ruleRegistryAlertsSearchStrategy', + RULE_SEARCH_STRATEGY_NAME, ruleRegistrySearchStrategy ); }); diff --git a/x-pack/plugins/rule_registry/server/search_strategy/index.ts b/x-pack/plugins/rule_registry/server/search_strategy/index.ts index 63f39430a5522..d6364983f2d26 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/index.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { ruleRegistrySearchStrategyProvider } from './search_strategy'; +export { ruleRegistrySearchStrategyProvider, RULE_SEARCH_STRATEGY_NAME } from './search_strategy'; diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts index 2ea4b4c191c0d..f5f7d8d164b48 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts @@ -8,7 +8,11 @@ import { of } from 'rxjs'; import { merge } from 'lodash'; import { loggerMock } from '@kbn/logging-mocks'; import { AlertConsumers } from '@kbn/rule-data-utils'; -import { ruleRegistrySearchStrategyProvider, EMPTY_RESPONSE } from './search_strategy'; +import { + ruleRegistrySearchStrategyProvider, + EMPTY_RESPONSE, + RULE_SEARCH_STRATEGY_NAME, +} from './search_strategy'; import { ruleDataServiceMock } from '../rule_data_plugin_service/rule_data_plugin_service.mock'; import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks'; import { SearchStrategyDependencies } from '../../../../../src/plugins/data/server'; @@ -18,6 +22,9 @@ import { spacesMock } from '../../../spaces/server/mocks'; import { RuleRegistrySearchRequest } from '../../common/search_strategy'; import { IndexInfo } from '../rule_data_plugin_service/index_info'; import * as getAuthzFilterImport from '../lib/get_authz_filter'; +import { getIsKibanaRequest } from '../lib/get_is_kibana_request'; + +jest.mock('../lib/get_is_kibana_request'); const getBasicResponse = (overwrites = {}) => { return merge( @@ -89,6 +96,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => { return of(response); }); + (getIsKibanaRequest as jest.Mock).mockImplementation(() => { + return true; + }); + getAuthzFilterSpy = jest .spyOn(getAuthzFilterImport, 'getAuthzFilter') .mockImplementation(async () => { @@ -377,4 +388,46 @@ describe('ruleRegistrySearchStrategyProvider()', () => { (data.search.searchAsInternalUser.search as jest.Mock).mock.calls[0][0].params.body.sort ).toStrictEqual([{ test: { order: 'desc' } }]); }); + + it('should reject, to the best of our ability, public requests', async () => { + (getIsKibanaRequest as jest.Mock).mockImplementation(() => { + return false; + }); + const request: RuleRegistrySearchRequest = { + featureIds: [AlertConsumers.LOGS], + sort: [ + { + test: { + order: 'desc', + }, + }, + ], + }; + const options = {}; + const deps = { + request: {}, + }; + + const strategy = ruleRegistrySearchStrategyProvider( + data, + ruleDataService, + alerting, + logger, + security, + spaces + ); + + let err = null; + try { + await strategy + .search(request, options, deps as unknown as SearchStrategyDependencies) + .toPromise(); + } catch (e) { + err = e; + } + expect(err).not.toBeNull(); + expect(err.message).toBe( + `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is currently only available for internal use.` + ); + }); }); diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts index 8cd0a0d410c9b..da32d68a85f86 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts @@ -5,6 +5,7 @@ * 2.0. */ import { map, mergeMap, catchError } from 'rxjs/operators'; +import Boom from '@hapi/boom'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { Logger } from 'src/core/server'; import { from, of } from 'rxjs'; @@ -23,11 +24,14 @@ import { Dataset } from '../rule_data_plugin_service/index_options'; import { MAX_ALERT_SEARCH_SIZE } from '../../common/constants'; import { AlertAuditAction, alertAuditEvent } from '../'; import { getSpacesFilter, getAuthzFilter } from '../lib'; +import { getIsKibanaRequest } from '../lib/get_is_kibana_request'; export const EMPTY_RESPONSE: RuleRegistrySearchResponse = { rawResponse: {} as RuleRegistrySearchResponse['rawResponse'], }; +export const RULE_SEARCH_STRATEGY_NAME = 'privateRuleRegistryAlertsSearchStrategy'; + export const ruleRegistrySearchStrategyProvider = ( data: PluginStart, ruleDataService: IRuleDataService, @@ -40,6 +44,13 @@ export const ruleRegistrySearchStrategyProvider = ( const requestUserEs = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY); return { search: (request, options, deps) => { + // We want to ensure this request came from our UI. We can't really do this + // but we have a best effort we can try + if (!getIsKibanaRequest(deps.request.headers)) { + throw Boom.notFound( + `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is currently only available for internal use.` + ); + } // SIEM uses RBAC fields in their alerts but also utilizes ES DLS which // is different than every other solution so we need to special case // those requests. @@ -48,7 +59,7 @@ export const ruleRegistrySearchStrategyProvider = ( siemRequest = true; } else if (request.featureIds.includes(AlertConsumers.SIEM)) { throw new Error( - 'The ruleRegistryAlertsSearchStrategy search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.' + `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.` ); } @@ -74,7 +85,7 @@ export const ruleRegistrySearchStrategyProvider = ( const indices: string[] = request.featureIds.reduce((accum: string[], featureId) => { if (!isValidFeatureId(featureId)) { logger.warn( - `Found invalid feature '${featureId}' while using rule registry search strategy. No alert data from this feature will be searched.` + `Found invalid feature '${featureId}' while using ${RULE_SEARCH_STRATEGY_NAME} search strategy. No alert data from this feature will be searched.` ); return accum; } diff --git a/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts b/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts index c27ca90e6e2f2..7f5d31b781310 100644 --- a/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts +++ b/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts @@ -21,6 +21,16 @@ interface ReturnValue { error?: string; } +interface ProfileResponse { + profile?: { shards: ShardSerialized[] }; + _shards: { + failed: number; + skipped: number; + total: number; + successful: number; + }; +} + const extractProfilerErrorMessage = (e: any): string | undefined => { if (e.body?.attributes?.error?.reason) { const { reason, line, col } = e.body.attributes.error; @@ -67,8 +77,7 @@ export const useRequestProfile = () => { try { const resp = await http.post< - | { ok: true; resp: { profile: { shards: ShardSerialized[] } } } - | { ok: false; err: { msg: string } } + { ok: true; resp: ProfileResponse } | { ok: false; err: { msg: string } } >('../api/searchprofiler/profile', { body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, @@ -78,7 +87,23 @@ export const useRequestProfile = () => { return { data: null, error: resp.err.msg }; } - return { data: resp.resp.profile.shards }; + // If a user attempts to run Search Profiler without any indices, + // _shards=0 and a "profile" output will not be returned + if (resp.resp._shards.total === 0) { + notifications.addDanger({ + 'data-test-subj': 'noShardsNotification', + title: i18n.translate('xpack.searchProfiler.errorNoShardsTitle', { + defaultMessage: 'Unable to profile', + }), + text: i18n.translate('xpack.searchProfiler.errorNoShardsDescription', { + defaultMessage: 'Verify your index input matches a valid index', + }), + }); + + return { data: null }; + } + + return { data: resp.resp.profile!.shards }; } catch (e) { const profilerErrorMessage = extractProfilerErrorMessage(e); if (profilerErrorMessage) { diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 2fd412eb357b6..cc64b7e640f1f 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -92,36 +92,38 @@ export enum SecurityPageName { detectionAndResponse = 'detection_response', endpoints = 'endpoints', eventFilters = 'event_filters', - hostIsolationExceptions = 'host_isolation_exceptions', events = 'events', exceptions = 'exceptions', explore = 'explore', + hostIsolationExceptions = 'host_isolation_exceptions', hosts = 'hosts', hostsAnomalies = 'hosts-anomalies', hostsExternalAlerts = 'hosts-external_alerts', hostsRisk = 'hosts-risk', - users = 'users', - usersAnomalies = 'users-anomalies', - usersRisk = 'users-risk', investigate = 'investigate', + landing = 'get_started', network = 'network', networkAnomalies = 'network-anomalies', networkDns = 'network-dns', networkExternalAlerts = 'network-external_alerts', networkHttp = 'network-http', networkTls = 'network-tls', - timelines = 'timelines', - timelinesTemplates = 'timelines-templates', overview = 'overview', policies = 'policies', rules = 'rules', + timelines = 'timelines', + timelinesTemplates = 'timelines-templates', trustedApps = 'trusted_apps', uncommonProcesses = 'uncommon_processes', + users = 'users', + usersAnomalies = 'users-anomalies', + usersRisk = 'users-risk', } export const TIMELINES_PATH = '/timelines' as const; export const CASES_PATH = '/cases' as const; export const OVERVIEW_PATH = '/overview' as const; +export const LANDING_PATH = '/get_started' as const; export const DETECTION_RESPONSE_PATH = '/detection_response' as const; export const DETECTIONS_PATH = '/detections' as const; export const ALERTS_PATH = '/alerts' as const; @@ -140,6 +142,7 @@ export const HOST_ISOLATION_EXCEPTIONS_PATH = export const BLOCKLIST_PATH = `${MANAGEMENT_PATH}/blocklist` as const; export const APP_OVERVIEW_PATH = `${APP_PATH}${OVERVIEW_PATH}` as const; +export const APP_LANDING_PATH = `${APP_PATH}${LANDING_PATH}` as const; export const APP_DETECTION_RESPONSE_PATH = `${APP_PATH}${DETECTION_RESPONSE_PATH}` as const; export const APP_MANAGEMENT_PATH = `${APP_PATH}${MANAGEMENT_PATH}` as const; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts index e6f2669c95c34..737d81cc9d1ed 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts @@ -256,19 +256,7 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator = { - [K in ConditionEntryField]?: T; + [K in AllConditionEntryFields]?: T; }; -export interface ConditionEntry< - F extends ConditionEntryField = ConditionEntryField, - T extends EntryTypes = EntryTypes -> { - field: F; +export interface ConditionEntry { + field: AllConditionEntryFields; type: T; operator: 'included'; value: string | string[]; diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index ab60d87973983..5e933efbbc61d 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -314,6 +314,8 @@ export type TimelineWithoutExternalRefs = Omit { + before(() => { + cleanKibana(); + loginAndWaitForPage(USERS_URL); + }); + + it(`renders events tab`, () => { + cy.get(EVENTS_TAB).click(); + + cy.get(EVENTS_TAB_CONTENT).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts new file mode 100644 index 0000000000000..a2b62bc892032 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EXTERNAL_ALERTS_TAB, + EXTERNAL_ALERTS_TAB_CONTENT, +} from '../../screens/users/user_external_alerts'; +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; + +import { USERS_URL } from '../../urls/navigation'; + +describe('Users external alerts tab', () => { + before(() => { + cleanKibana(); + loginAndWaitForPage(USERS_URL); + }); + + it(`renders external alerts tab`, () => { + cy.get(EXTERNAL_ALERTS_TAB).click(); + + cy.get(EXTERNAL_ALERTS_TAB_CONTENT).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/overview.ts b/x-pack/plugins/security_solution/cypress/screens/overview.ts index e478f16e72844..42f16340e6ac6 100644 --- a/x-pack/plugins/security_solution/cypress/screens/overview.ts +++ b/x-pack/plugins/security_solution/cypress/screens/overview.ts @@ -144,7 +144,7 @@ export const OVERVIEW_HOST_STATS = '[data-test-subj="overview-hosts-stats"]'; export const OVERVIEW_NETWORK_STATS = '[data-test-subj="overview-network-stats"]'; -export const OVERVIEW_EMPTY_PAGE = '[data-test-subj="empty-page"]'; +export const OVERVIEW_EMPTY_PAGE = '[data-test-subj="siem-landing-page"]'; export const OVERVIEW_REVENT_TIMELINES = '[data-test-subj="overview-recent-timelines"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/users/user_events.ts b/x-pack/plugins/security_solution/cypress/screens/users/user_events.ts new file mode 100644 index 0000000000000..c2bcd30f9d1c2 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/users/user_events.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const EVENTS_TAB = '[data-test-subj="navigation-events"]'; +export const EVENTS_TAB_CONTENT = '[data-test-subj="events-viewer-panel"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts b/x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts new file mode 100644 index 0000000000000..bc98b3bc59f37 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const EXTERNAL_ALERTS_TAB = '[data-test-subj="navigation-externalAlerts"]'; +export const EXTERNAL_ALERTS_TAB_CONTENT = '[data-test-subj="events-viewer-panel"]'; diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 144095d0aa528..efb220467c9d0 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -32,9 +32,11 @@ import { TRUSTED_APPLICATIONS, POLICIES, ENDPOINTS, + GETTING_STARTED, } from '../translations'; import { OVERVIEW_PATH, + LANDING_PATH, DETECTION_RESPONSE_PATH, ALERTS_PATH, RULES_PATH, @@ -84,6 +86,18 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ ], order: 9000, }, + { + id: SecurityPageName.landing, + title: GETTING_STARTED, + path: LANDING_PATH, + navLinkStatus: AppNavLinkStatus.visible, + features: [FEATURE.general], + keywords: [ + i18n.translate('xpack.securitySolution.search.getStarted', { + defaultMessage: 'Getting started', + }), + ], + }, { id: SecurityPageName.detectionAndResponse, title: DETECTION_RESPONSE, diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts index 0b06d02d46464..1ae5544dbd740 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts @@ -30,6 +30,7 @@ import { SecurityPageName, APP_HOST_ISOLATION_EXCEPTIONS_PATH, APP_USERS_PATH, + APP_LANDING_PATH, } from '../../../common/constants'; export const navTabs: SecurityNav = { @@ -40,6 +41,13 @@ export const navTabs: SecurityNav = { disabled: false, urlKey: 'overview', }, + [SecurityPageName.landing]: { + id: SecurityPageName.landing, + name: i18n.GETTING_STARTED, + href: APP_LANDING_PATH, + disabled: false, + urlKey: 'get_started', + }, [SecurityPageName.detectionAndResponse]: { id: SecurityPageName.detectionAndResponse, name: i18n.DETECTION_RESPONSE, diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 2e0743de69043..f0ebb711f1f38 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -22,6 +22,10 @@ export const HOSTS = i18n.translate('xpack.securitySolution.navigation.hosts', { defaultMessage: 'Hosts', }); +export const GETTING_STARTED = i18n.translate('xpack.securitySolution.navigation.gettingStarted', { + defaultMessage: 'Getting started', +}); + export const NETWORK = i18n.translate('xpack.securitySolution.navigation.network', { defaultMessage: 'Network', }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx new file mode 100644 index 0000000000000..7abca14a2e55f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { TimelineId } from '../../../../common/types'; +import { HostsType } from '../../../hosts/store/model'; +import { TestProviders } from '../../mock'; +import { EventsQueryTabBody, EventsQueryTabBodyComponentProps } from './events_query_tab_body'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; +import * as tGridActions from '../../../../../timelines/public/store/t_grid/actions'; + +jest.mock('../../lib/kibana', () => { + const original = jest.requireActual('../../lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + cases: { + ui: { + getCasesContext: jest.fn(), + }, + }, + }, + }), + }; +}); + +const FakeStatefulEventsViewer = () =>
{'MockedStatefulEventsViewer'}
; +jest.mock('../events_viewer', () => ({ StatefulEventsViewer: FakeStatefulEventsViewer })); + +jest.mock('../../containers/use_full_screen', () => ({ + useGlobalFullScreen: jest.fn().mockReturnValue({ + globalFullScreen: true, + }), +})); + +describe('EventsQueryTabBody', () => { + const commonProps: EventsQueryTabBodyComponentProps = { + indexNames: ['test-index'], + setQuery: jest.fn(), + timelineId: TimelineId.test, + type: HostsType.page, + endDate: new Date('2000').toISOString(), + startDate: new Date('2000').toISOString(), + }; + + it('renders EventsViewer', () => { + const { queryByText } = render( + + + + ); + + expect(queryByText('MockedStatefulEventsViewer')).toBeInTheDocument(); + }); + + it('renders the matrix histogram when globalFullScreen is false', () => { + (useGlobalFullScreen as jest.Mock).mockReturnValue({ + globalFullScreen: false, + }); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('eventsHistogramQueryPanel')).toBeInTheDocument(); + }); + + it("doesn't render the matrix histogram when globalFullScreen is true", () => { + (useGlobalFullScreen as jest.Mock).mockReturnValue({ + globalFullScreen: true, + }); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('eventsHistogramQueryPanel')).not.toBeInTheDocument(); + }); + + it('deletes query when unmouting', () => { + const mockDeleteQuery = jest.fn(); + const { unmount } = render( + + + + ); + unmount(); + + expect(mockDeleteQuery).toHaveBeenCalled(); + }); + + it('initializes t-grid', () => { + const spy = jest.spyOn(tGridActions, 'initializeTGridSettings'); + render( + + + + ); + + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx similarity index 72% rename from x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx rename to x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx index 59c3322fb02ed..cfd6546470d4a 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx @@ -8,27 +8,28 @@ import React, { useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; +import { Filter } from '@kbn/es-query'; import { TimelineId } from '../../../../common/types/timeline'; -import { StatefulEventsViewer } from '../../../common/components/events_viewer'; +import { StatefulEventsViewer } from '../events_viewer'; import { timelineActions } from '../../../timelines/store/timeline'; -import { HostsComponentsQueryProps } from './types'; -import { eventsDefaultModel } from '../../../common/components/events_viewer/default_model'; -import { - MatrixHistogramOption, - MatrixHistogramConfigs, -} from '../../../common/components/matrix_histogram/types'; -import { MatrixHistogram } from '../../../common/components/matrix_histogram'; -import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; -import * as i18n from '../translations'; +import { eventsDefaultModel } from '../events_viewer/default_model'; + +import { MatrixHistogram } from '../matrix_histogram'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; +import * as i18n from '../../../hosts/pages/translations'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { getDefaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; -import { SourcererScopeName } from '../../../common/store/sourcerer/model'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { SourcererScopeName } from '../../store/sourcerer/model'; +import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; -import { defaultCellActions } from '../../../common/lib/cell_actions/default_cell_actions'; import { getEventsHistogramLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/hosts/events'; +import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions'; +import { GlobalTimeArgs } from '../../containers/use_global_time'; +import { MatrixHistogramConfigs, MatrixHistogramOption } from '../matrix_histogram/types'; +import { QueryTabBodyProps as UserQueryTabBodyProps } from '../../../users/pages/navigation/types'; +import { QueryTabBodyProps as HostQueryTabBodyProps } from '../../../hosts/pages/navigation/types'; const EVENTS_HISTOGRAM_ID = 'eventsHistogramQuery'; @@ -61,7 +62,17 @@ export const histogramConfigs: MatrixHistogramConfigs = { getLensAttributes: getEventsHistogramLensAttributes, }; -const EventsQueryTabBodyComponent: React.FC = ({ +type QueryTabBodyProps = UserQueryTabBodyProps | HostQueryTabBodyProps; + +export type EventsQueryTabBodyComponentProps = QueryTabBodyProps & { + deleteQuery?: GlobalTimeArgs['deleteQuery']; + indexNames: string[]; + pageFilters?: Filter[]; + setQuery: GlobalTimeArgs['setQuery']; + timelineId: TimelineId; +}; + +const EventsQueryTabBodyComponent: React.FC = ({ deleteQuery, endDate, filterQuery, @@ -69,6 +80,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ pageFilters, setQuery, startDate, + timelineId, }) => { const dispatch = useDispatch(); const { globalFullScreen } = useGlobalFullScreen(); @@ -78,7 +90,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ useEffect(() => { dispatch( timelineActions.initializeTGridSettings({ - id: TimelineId.hostsPageEvents, + id: timelineId, defaultColumns: eventsDefaultModel.columns.map((c) => !tGridEnabled && c.initialWidth == null ? { @@ -89,7 +101,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ ), }) ); - }, [dispatch, tGridEnabled]); + }, [dispatch, tGridEnabled, timelineId]); useEffect(() => { return () => { @@ -119,7 +131,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ defaultModel={eventsDefaultModel} end={endDate} entityType="events" - id={TimelineId.hostsPageEvents} + id={timelineId} leadingControlColumns={leadingControlColumns} pageFilters={pageFilters} renderCellValue={DefaultCellRenderer} diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap index 6701224289e66..45a6e20cf087d 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap @@ -18,19 +18,25 @@ exports[`HeaderSection it renders 1`] = ` responsive={false} > - -

- + - Test title - -

-
+

+ + Test title + +

+ +
+
diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx index 5ec97ea59bc1d..2296dc78241f2 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx @@ -180,4 +180,94 @@ describe('HeaderSection', () => { expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false); }); + + test('it does not render query-toggle-header when no arguments provided', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().exists()).toBe(false); + }); + + test('it does render query-toggle-header when toggleQuery arguments provided', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().exists()).toBe(true); + }); + + test('it does render everything but title when toggleStatus = true', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().prop('iconType')).toBe( + 'arrowDown' + ); + expect(wrapper.find('[data-test-subj="header-section-supplements"]').first().exists()).toBe( + true + ); + expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(true); + expect(wrapper.find('[data-test-subj="header-section-filters"]').first().exists()).toBe(true); + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(true); + }); + test('it does not render anything but title when toggleStatus = false', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().prop('iconType')).toBe( + 'arrowRight' + ); + expect(wrapper.find('[data-test-subj="header-section-supplements"]').first().exists()).toBe( + false + ); + expect(wrapper.find('[data-test-subj="header-section-filters"]').first().exists()).toBe(false); + expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(false); + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false); + }); + + test('it toggles query when icon is clicked', () => { + const mockToggle = jest.fn(); + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockToggle).toBeCalledWith(false); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx index ae07a03ba6407..7997dfa83e27b 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx @@ -5,13 +5,21 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiTitle, EuiTitleSize } from '@elastic/eui'; -import React from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiTitle, + EuiTitleSize, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; import styled, { css } from 'styled-components'; import { InspectButton } from '../inspect'; import { Subtitle } from '../subtitle'; +import * as i18n from '../../containers/query_toggle/translations'; interface HeaderProps { border?: boolean; @@ -51,6 +59,8 @@ export interface HeaderSectionProps extends HeaderProps { split?: boolean; stackHeader?: boolean; subtitle?: string | React.ReactNode; + toggleQuery?: (status: boolean) => void; + toggleStatus?: boolean; title: string | React.ReactNode; titleSize?: EuiTitleSize; tooltip?: string; @@ -72,56 +82,87 @@ const HeaderSectionComponent: React.FC = ({ subtitle, title, titleSize = 'm', + toggleQuery, + toggleStatus = true, tooltip, -}) => ( -
- - - - - -

- {title} - {tooltip && ( - <> - {' '} - - +}) => { + const toggle = useCallback(() => { + if (toggleQuery) { + toggleQuery(!toggleStatus); + } + }, [toggleQuery, toggleStatus]); + return ( +
+ + + + + + {toggleQuery && ( + + + )} -

-
+ + +

+ {title} + {tooltip && ( + <> + {' '} + + + )} +

+
+
+
- {!hideSubtitle && ( - - )} -
- - {id && showInspectButton && ( - - + {!hideSubtitle && toggleStatus && ( + + )} - )} - {headerFilters && {headerFilters}} -
- + {id && showInspectButton && toggleStatus && ( + + + + )} - {children && ( - - {children} + {headerFilters && toggleStatus && ( + + {headerFilters} + + )} + - )} - -
-); + + {children && toggleStatus && ( + + {children} + + )} +
+ + ); +}; export const HeaderSection = React.memo(HeaderSectionComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx index 3f34b857615fe..6a83edd7442de 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx @@ -6,6 +6,9 @@ */ import { appendSearch } from './helpers'; +import { LANDING_PATH } from '../../../../common/constants'; export const getAppOverviewUrl = (overviewPath: string, search?: string) => `${overviewPath}${appendSearch(search)}`; + +export const getAppLandingUrl = (search?: string) => `${LANDING_PATH}${appendSearch(search)}`; diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx index aee49bd1b00ae..1de9e08b4c65c 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx @@ -15,6 +15,9 @@ import { TestProviders } from '../../mock'; import { mockRuntimeMappings } from '../../containers/source/mock'; import { dnsTopDomainsLensAttributes } from '../visualization_actions/lens_attributes/network/dns_top_domains'; import { useRouteSpy } from '../../utils/route/use_route_spy'; +import { useQueryToggle } from '../../containers/query_toggle'; + +jest.mock('../../containers/query_toggle'); jest.mock('../../lib/kibana'); jest.mock('./matrix_loader', () => ({ @@ -25,9 +28,7 @@ jest.mock('../charts/barchart', () => ({ BarChart: () =>
, })); -jest.mock('../../containers/matrix_histogram', () => ({ - useMatrixHistogramCombined: jest.fn(), -})); +jest.mock('../../containers/matrix_histogram'); jest.mock('../visualization_actions', () => ({ VisualizationActions: jest.fn(({ className }: { className: string }) => ( @@ -78,9 +79,13 @@ describe('Matrix Histogram Component', () => { title: 'mockTitle', runtimeMappings: mockRuntimeMappings, }; - - beforeAll(() => { - (useMatrixHistogramCombined as jest.Mock).mockReturnValue([ + const mockUseMatrix = useMatrixHistogramCombined as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockSetToggle = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseMatrix.mockReturnValue([ false, { data: null, @@ -88,14 +93,16 @@ describe('Matrix Histogram Component', () => { totalCount: null, }, ]); - wrapper = mount(, { - wrappingComponent: TestProviders, - }); }); describe('on initial load', () => { + beforeEach(() => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + }); test('it requests Matrix Histogram', () => { - expect(useMatrixHistogramCombined).toHaveBeenCalledWith({ + expect(mockUseMatrix).toHaveBeenCalledWith({ endDate: mockMatrixOverTimeHistogramProps.endDate, errorMessage: mockMatrixOverTimeHistogramProps.errorMessage, histogramType: mockMatrixOverTimeHistogramProps.histogramType, @@ -114,6 +121,9 @@ describe('Matrix Histogram Component', () => { describe('spacer', () => { test('it renders a spacer by default', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); expect(wrapper.find('[data-test-subj="spacer"]').exists()).toBe(true); }); @@ -129,8 +139,11 @@ describe('Matrix Histogram Component', () => { }); describe('not initial load', () => { - beforeAll(() => { - (useMatrixHistogramCombined as jest.Mock).mockReturnValue([ + beforeEach(() => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + mockUseMatrix.mockReturnValue([ false, { data: [ @@ -159,6 +172,9 @@ describe('Matrix Histogram Component', () => { describe('select dropdown', () => { test('should be hidden if only one option is provided', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); expect(wrapper.find('EuiSelect').exists()).toBe(false); }); }); @@ -287,4 +303,53 @@ describe('Matrix Histogram Component', () => { expect(wrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(false); }); }); + + describe('toggle query', () => { + const testProps = { + ...mockMatrixOverTimeHistogramProps, + lensAttributes: dnsTopDomainsLensAttributes, + }; + + test('toggleQuery updates toggleStatus', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(mockUseMatrix.mock.calls[0][0].skip).toEqual(false); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockUseMatrix.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseMatrix.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('MatrixLoader').exists()).toBe(true); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('MatrixLoader').exists()).toBe(false); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseMatrix.mock.calls[0][0].skip).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx index dbf525f8e14cb..488948de074f6 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx @@ -34,6 +34,7 @@ import { GetLensAttributes, LensAttributes } from '../visualization_actions/type import { useKibana, useGetUserCasesPermissions } from '../../lib/kibana'; import { APP_ID, SecurityPageName } from '../../../../common/constants'; import { useRouteSpy } from '../../utils/route/use_route_spy'; +import { useQueryToggle } from '../../containers/query_toggle'; export type MatrixHistogramComponentProps = MatrixHistogramProps & Omit & { @@ -148,6 +149,19 @@ export const MatrixHistogramComponent: React.FC = }, [defaultStackByOption, stackByOptions] ); + const { toggleStatus, setToggleStatus } = useQueryToggle(id); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); const matrixHistogramRequest = { endDate, @@ -161,9 +175,8 @@ export const MatrixHistogramComponent: React.FC = runtimeMappings, isPtrIncluded, docValueFields, - skip, + skip: querySkip, }; - const [loading, { data, inspect, totalCount, refetch }] = useMatrixHistogramCombined(matrixHistogramRequest); const [{ pageName }] = useRouteSpy(); @@ -225,7 +238,7 @@ export const MatrixHistogramComponent: React.FC = > {loading && !isInitialLoading && ( @@ -239,8 +252,11 @@ export const MatrixHistogramComponent: React.FC = = {headerChildren} - - {isInitialLoading ? ( - - ) : ( - - )} + {toggleStatus ? ( + isInitialLoading ? ( + + ) : ( + + ) + ) : null} {showSpacer && } diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx index efa4ba4c6eb0f..8eca508a4b74b 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx @@ -10,7 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import styled from 'styled-components'; const StyledEuiFlexGroup = styled(EuiFlexGroup)` - flex 1; + flex: 1; `; const MatrixLoaderComponent = () => ( diff --git a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts index f1cab9c2f441d..58610298d4395 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts @@ -80,7 +80,9 @@ export const useAnomaliesTableData = ({ earliestMs: number, latestMs: number ) { - if (isMlUser && !skip && jobIds.length > 0) { + if (skip) { + setLoading(false); + } else if (isMlUser && !skip && jobIds.length > 0) { try { const data = await anomaliesTableData( { diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx new file mode 100644 index 0000000000000..7701880bd7b2e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx @@ -0,0 +1,88 @@ +/* + * 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 { mount } from 'enzyme'; +import { AnomaliesHostTable } from './anomalies_host_table'; +import { TestProviders } from '../../../mock'; +import React from 'react'; +import { useQueryToggle } from '../../../containers/query_toggle'; +import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; +import { HostsType } from '../../../../hosts/store/model'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; + +jest.mock('../../../containers/query_toggle'); +jest.mock('../anomaly/use_anomalies_table_data'); +jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); + +describe('Anomalies host table', () => { + describe('toggle query', () => { + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock; + const mockSetToggle = jest.fn(); + const testProps = { + startDate: '2019-07-17T20:00:00.000Z', + endDate: '2019-07-18T20:00:00.000Z', + narrowDateRange: jest.fn(), + skip: false, + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + (hasMlUserPermissions as jest.Mock).mockReturnValue(true); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseAnomaliesTableData.mockReturnValue([ + false, + { + anomalies: [], + interval: '10', + }, + ]); + }); + + test('toggleQuery updates toggleStatus', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockUseAnomaliesTableData.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="host-anomalies-table"]').exists()).toBe(true); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="host-anomalies-table"]').exists()).toBe(false); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx index 318f452e0c1df..eec90e6117c28 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -21,6 +21,7 @@ import { BasicTable } from './basic_table'; import { getCriteriaFromHostType } from '../criteria/get_criteria_from_host_type'; import { Panel } from '../../panel'; import { anomaliesTableDefaultEquality } from './default_equality'; +import { useQueryToggle } from '../../../containers/query_toggle'; const sorting = { sort: { @@ -37,10 +38,24 @@ const AnomaliesHostTableComponent: React.FC = ({ type, }) => { const capabilities = useMlCapabilities(); + const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesHostTable`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, - skip, + skip: querySkip, criteriaFields: getCriteriaFromHostType(type, hostName), filterQuery: { exists: { field: 'host.name' }, @@ -64,21 +79,26 @@ const AnomaliesHostTableComponent: React.FC = ({ return ( - - type is not as specific as EUI's... - columns={columns} - items={hosts} - pagination={pagination} - sorting={sorting} - /> + {toggleStatus && ( + type is not as specific as EUI's... + columns={columns} + items={hosts} + pagination={pagination} + sorting={sorting} + /> + )} {loading && ( diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx new file mode 100644 index 0000000000000..b7491562a5d72 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 { mount } from 'enzyme'; +import { AnomaliesNetworkTable } from './anomalies_network_table'; +import { TestProviders } from '../../../mock'; +import React from 'react'; +import { useQueryToggle } from '../../../containers/query_toggle'; +import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; +import { NetworkType } from '../../../../network/store/model'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; +import { FlowTarget } from '../../../../../common/search_strategy'; + +jest.mock('../../../containers/query_toggle'); +jest.mock('../anomaly/use_anomalies_table_data'); +jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); + +describe('Anomalies network table', () => { + describe('toggle query', () => { + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock; + const mockSetToggle = jest.fn(); + const testProps = { + startDate: '2019-07-17T20:00:00.000Z', + endDate: '2019-07-18T20:00:00.000Z', + flowTarget: FlowTarget.destination, + narrowDateRange: jest.fn(), + skip: false, + type: NetworkType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + (hasMlUserPermissions as jest.Mock).mockReturnValue(true); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseAnomaliesTableData.mockReturnValue([ + false, + { + anomalies: [], + interval: '10', + }, + ]); + }); + + test('toggleQuery updates toggleStatus', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockUseAnomaliesTableData.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="network-anomalies-table"]').exists()).toBe(true); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="network-anomalies-table"]').exists()).toBe(false); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx index 78795c6d3614a..242114a806ca8 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -20,6 +20,7 @@ import { BasicTable } from './basic_table'; import { networkEquality } from './network_equality'; import { getCriteriaFromNetworkType } from '../criteria/get_criteria_from_network_type'; import { Panel } from '../../panel'; +import { useQueryToggle } from '../../../containers/query_toggle'; const sorting = { sort: { @@ -37,10 +38,25 @@ const AnomaliesNetworkTableComponent: React.FC = ({ flowTarget, }) => { const capabilities = useMlCapabilities(); + + const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesNetwork-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, - skip, + skip: querySkip, criteriaFields: getCriteriaFromNetworkType(type, ip, flowTarget), }); @@ -63,18 +79,23 @@ const AnomaliesNetworkTableComponent: React.FC = ({ subtitle={`${i18n.SHOWING}: ${pagination.totalItemCount.toLocaleString()} ${i18n.UNIT( pagination.totalItemCount )}`} + height={!toggleStatus ? 40 : undefined} title={i18n.ANOMALIES} tooltip={i18n.TOOLTIP} + toggleQuery={toggleQuery} + toggleStatus={toggleStatus} isInspectDisabled={skip} /> - - type is not as specific as EUI's... - columns={columns} - items={networks} - pagination={pagination} - sorting={sorting} - /> + {toggleStatus && ( + type is not as specific as EUI's... + columns={columns} + items={networks} + pagination={pagination} + sorting={sorting} + /> + )} {loading && ( diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx new file mode 100644 index 0000000000000..40aab638b854a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx @@ -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 { mount } from 'enzyme'; +import { AnomaliesUserTable } from './anomalies_user_table'; +import { TestProviders } from '../../../mock'; +import React from 'react'; +import { useQueryToggle } from '../../../containers/query_toggle'; +import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; +import { UsersType } from '../../../../users/store/model'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; + +jest.mock('../../../containers/query_toggle'); +jest.mock('../anomaly/use_anomalies_table_data'); +jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); + +describe('Anomalies user table', () => { + describe('toggle query', () => { + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock; + const mockSetToggle = jest.fn(); + const testProps = { + startDate: '2019-07-17T20:00:00.000Z', + endDate: '2019-07-18T20:00:00.000Z', + narrowDateRange: jest.fn(), + userName: 'coolguy', + skip: false, + type: UsersType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + (hasMlUserPermissions as jest.Mock).mockReturnValue(true); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseAnomaliesTableData.mockReturnValue([ + false, + { + anomalies: [], + interval: '10', + }, + ]); + }); + + test('toggleQuery updates toggleStatus', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockUseAnomaliesTableData.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="user-anomalies-table"]').exists()).toBe(true); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="user-anomalies-table"]').exists()).toBe(false); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx index 061f2c04cef6d..c67455c0772b9 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -23,6 +23,7 @@ import { Panel } from '../../panel'; import { anomaliesTableDefaultEquality } from './default_equality'; import { convertAnomaliesToUsers } from './convert_anomalies_to_users'; import { getAnomaliesUserTableColumnsCurated } from './get_anomalies_user_table_columns'; +import { useQueryToggle } from '../../../containers/query_toggle'; const sorting = { sort: { @@ -40,10 +41,24 @@ const AnomaliesUserTableComponent: React.FC = ({ }) => { const capabilities = useMlCapabilities(); + const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesUserTable`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, - skip, + skip: querySkip, criteriaFields: getCriteriaFromUsersType(type, userName), filterQuery: { exists: { field: 'user.name' }, @@ -67,21 +82,27 @@ const AnomaliesUserTableComponent: React.FC = ({ return ( - type is not as specific as EUI's... - columns={columns} - items={users} - pagination={pagination} - sorting={sorting} - /> + {toggleStatus && ( + type is not as specific as EUI's... + columns={columns} + items={users} + pagination={pagination} + sorting={sorting} + /> + )} {loading && ( diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 0a4f12e348eff..b1903ef869d3d 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -47,6 +47,7 @@ export type SecurityNavKey = | SecurityPageName.detectionAndResponse | SecurityPageName.case | SecurityPageName.endpoints + | SecurityPageName.landing | SecurityPageName.policies | SecurityPageName.eventFilters | SecurityPageName.exceptions diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index a00ea4b6bf520..601794dd25917 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -125,6 +125,16 @@ describe('useSecuritySolutionNavigation', () => { "name": "Overview", "onClick": [Function], }, + Object { + "data-href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-get_started", + "disabled": false, + "href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "get_started", + "isSelected": false, + "name": "Getting started", + "onClick": [Function], + }, ], "name": "", }, @@ -286,8 +296,7 @@ describe('useSecuritySolutionNavigation', () => { () => useSecuritySolutionNavigation(), { wrapper: TestProviders } ); - - expect(result?.current?.items?.[0].items?.[1].id).toEqual( + expect(result?.current?.items?.[0].items?.[2].id).toEqual( SecurityPageName.detectionAndResponse ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx index 677632d20e718..14b007be4764d 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -78,6 +78,7 @@ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { name: '', items: [ navTabs[SecurityPageName.overview], + navTabs[SecurityPageName.landing], // Temporary check for detectionAndResponse while page is feature flagged ...(navTabs[SecurityPageName.detectionAndResponse] != null ? [navTabs[SecurityPageName.detectionAndResponse]] diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap index a2fffc32be46d..bf03d637e8811 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -11,6 +11,8 @@ exports[`Paginated Table Component rendering it renders the default load more ta

@@ -58,6 +60,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta }, ] } + data-test-subj="paginated-basic-table" items={ Array [ Object { diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx index 0c09dce9c07cb..57686126dfb10 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx @@ -15,6 +15,8 @@ import { getHostsColumns, mockData, rowItems, sortedHosts } from './index.mock'; import { ThemeProvider } from 'styled-components'; import { getMockTheme } from '../../lib/kibana/kibana_react.mock'; import { Direction } from '../../../../common/search_strategy'; +import { useQueryToggle } from '../../containers/query_toggle'; +jest.mock('../../containers/query_toggle'); jest.mock('react', () => { const r = jest.requireActual('react'); @@ -36,37 +38,41 @@ const mockTheme = getMockTheme({ }); describe('Paginated Table Component', () => { - let loadPage: jest.Mock; - let updateLimitPagination: jest.Mock; - let updateActivePage: jest.Mock; + const loadPage = jest.fn(); + const updateLimitPagination = jest.fn(); + const updateActivePage = jest.fn(); + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockSetToggle = jest.fn(); + const mockSetQuerySkip = jest.fn(); + beforeEach(() => { - loadPage = jest.fn(); - updateLimitPagination = jest.fn(); - updateActivePage = jest.fn(); + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); }); + const testProps = { + activePage: 0, + columns: getHostsColumns(), + headerCount: 1, + headerSupplement:

{'My test supplement.'}

, + headerTitle: 'Hosts', + headerTooltip: 'My test tooltip', + headerUnit: 'Test Unit', + itemsPerRow: rowItems, + limit: 1, + loading: false, + loadPage, + pageOfItems: mockData.Hosts.edges, + setQuerySkip: jest.fn(), + showMorePagesIndicator: true, + totalCount: 10, + updateActivePage, + updateLimitPagination: (limit: number) => updateLimitPagination({ limit }), + }; + describe('rendering', () => { test('it renders the default load more table', () => { - const wrapper = shallow( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> - ); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); @@ -74,24 +80,7 @@ describe('Paginated Table Component', () => { test('it renders the loading panel at the beginning ', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadPage={loadPage} - pageOfItems={[]} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -103,24 +92,7 @@ describe('Paginated Table Component', () => { test('it renders the over loading panel after data has been in the table ', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -130,24 +102,7 @@ describe('Paginated Table Component', () => { test('it renders the correct amount of pages and starts at activePage: 0', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -167,24 +122,7 @@ describe('Paginated Table Component', () => { test('it render popover to select new limit in table', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -195,24 +133,7 @@ describe('Paginated Table Component', () => { test('it will NOT render popover to select new limit in table if props itemsPerRow is empty', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={[]} - limit={2} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -224,24 +145,11 @@ describe('Paginated Table Component', () => { const wrapper = mount( {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} limit={2} - loading={false} - loadPage={jest.fn()} onChange={mockOnChange} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} sorting={{ direction: Direction.asc, field: 'node.host.name' }} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} />
); @@ -253,22 +161,9 @@ describe('Paginated Table Component', () => { const wrapper = mount( {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} + {...testProps} limit={DEFAULT_MAX_TABLE_QUERY_SIZE} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} totalCount={DEFAULT_MAX_TABLE_QUERY_SIZE * 3} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} />
); @@ -279,24 +174,7 @@ describe('Paginated Table Component', () => { test('Should show items per row if totalCount is greater than items', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={DEFAULT_MAX_TABLE_QUERY_SIZE} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={30} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeTruthy(); @@ -305,24 +183,7 @@ describe('Paginated Table Component', () => { test('Should hide items per row if totalCount is less than items', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={DEFAULT_MAX_TABLE_QUERY_SIZE} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={1} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeFalsy(); @@ -331,24 +192,7 @@ describe('Paginated Table Component', () => { test('Should hide pagination if totalCount is zero', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={DEFAULT_MAX_TABLE_QUERY_SIZE} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={0} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -360,24 +204,7 @@ describe('Paginated Table Component', () => { test('should call updateActivePage with 1 when clicking to the first page', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); wrapper.find('[data-test-subj="pagination-button-next"]').first().simulate('click'); @@ -387,24 +214,7 @@ describe('Paginated Table Component', () => { test('Should call updateActivePage with 0 when you pick a new limit', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); wrapper.find('[data-test-subj="pagination-button-next"]').first().simulate('click'); @@ -417,22 +227,8 @@ describe('Paginated Table Component', () => { test('should update the page when the activePage is changed from redux', () => { const ourProps: BasicTableProps = { + ...testProps, activePage: 3, - columns: getHostsColumns(), - headerCount: 1, - headerSupplement:

{'My test supplement.'}

, - headerTitle: 'Hosts', - headerTooltip: 'My test tooltip', - headerUnit: 'Test Unit', - itemsPerRow: rowItems, - limit: 1, - loading: false, - loadPage, - pageOfItems: mockData.Hosts.edges, - showMorePagesIndicator: true, - totalCount: 10, - updateActivePage, - updateLimitPagination: (limit) => updateLimitPagination({ limit }), }; // enzyme does not allow us to pass props to child of HOC @@ -462,24 +258,7 @@ describe('Paginated Table Component', () => { test('Should call updateLimitPagination when you pick a new limit', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -494,24 +273,11 @@ describe('Paginated Table Component', () => { const wrapper = mount( {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} limit={2} - loading={false} - loadPage={jest.fn()} onChange={mockOnChange} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} sorting={{ direction: Direction.asc, field: 'node.host.name' }} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} />
); @@ -524,4 +290,41 @@ describe('Paginated Table Component', () => { ]); }); }); + + describe('Toggle query', () => { + test('toggleQuery updates toggleStatus', () => { + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockSetQuerySkip).toBeCalledWith(true); + }); + + test('toggleStatus=true, render table', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="paginated-basic-table"]').first().exists()).toEqual( + true + ); + }); + + test('toggleStatus=false, hide table', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="paginated-basic-table"]').first().exists()).toEqual( + false + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx index 310ab039057c2..b9de144c5735e 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx @@ -20,7 +20,7 @@ import { EuiTableRowCellProps, } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React, { FC, memo, useState, useMemo, useEffect, ComponentType } from 'react'; +import React, { FC, memo, useState, useMemo, useEffect, ComponentType, useCallback } from 'react'; import styled from 'styled-components'; import { Direction } from '../../../../common/search_strategy'; @@ -49,6 +49,7 @@ import { useStateToaster } from '../toasters'; import * as i18n from './translations'; import { Panel } from '../panel'; import { InspectButtonContainer } from '../inspect'; +import { useQueryToggle } from '../../containers/query_toggle'; const DEFAULT_DATA_TEST_SUBJ = 'paginated-table'; @@ -113,6 +114,7 @@ export interface BasicTableProps { onChange?: (criteria: Criteria) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any pageOfItems: any[]; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; sorting?: SortingBasicTable; split?: boolean; @@ -153,6 +155,7 @@ const PaginatedTableComponent: FC = ({ loadPage, onChange = noop, pageOfItems, + setQuerySkip, showMorePagesIndicator, sorting = null, split, @@ -253,10 +256,24 @@ const PaginatedTableComponent: FC = ({ [sorting] ); + const { toggleStatus, setToggleStatus } = useQueryToggle(id); + + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + return ( = ({ > {!loadingInitial && headerSupplement} - - {loadingInitial ? ( - - ) : ( - <> - - - - {itemsPerRow && itemsPerRow.length > 0 && totalCount >= itemsPerRow[0].numberOfRow && ( - - - - )} - - - - {totalCount > 0 && ( - - )} - - - {(isInspect || myLoading) && ( - - )} - - )} + {toggleStatus && + (loadingInitial ? ( + + ) : ( + <> + + + + {itemsPerRow && + itemsPerRow.length > 0 && + totalCount >= itemsPerRow[0].numberOfRow && ( + + + + )} + + + + {totalCount > 0 && ( + + )} + + + {(isInspect || myLoading) && ( + + )} + + ))} ); diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx index 5f2c76632aba9..944eeb8b42a57 100644 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx @@ -41,6 +41,7 @@ import { NetworkKpiStrategyResponse, } from '../../../../common/search_strategy'; import { getMockTheme } from '../../lib/kibana/kibana_react.mock'; +import * as module from '../../containers/query_toggle'; const from = '2019-06-15T06:00:00.000Z'; const to = '2019-06-18T06:00:00.000Z'; @@ -53,26 +54,37 @@ jest.mock('../charts/barchart', () => { return { BarChart: () =>
}; }); +const mockSetToggle = jest.fn(); + +jest + .spyOn(module, 'useQueryToggle') + .mockImplementation(() => ({ toggleStatus: true, setToggleStatus: mockSetToggle })); +const mockSetQuerySkip = jest.fn(); describe('Stat Items Component', () => { const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } }); const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - + const testProps = { + description: 'HOSTS', + fields: [{ key: 'hosts', value: null, color: '#6092C0', icon: 'cross' }], + from, + id: 'statItems', + key: 'mock-keys', + loading: false, + setQuerySkip: mockSetQuerySkip, + to, + narrowDateRange: mockNarrowDateRange, + }; + beforeEach(() => { + jest.clearAllMocks(); + }); describe.each([ [ mount( - + ), @@ -81,17 +93,7 @@ describe('Stat Items Component', () => { mount( - + ), @@ -118,62 +120,59 @@ describe('Stat Items Component', () => { }); }); + const mockStatItemsData: StatItemsProps = { + ...testProps, + areaChart: [ + { + key: 'uniqueSourceIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 }, + { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 }, + { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 }, + ], + color: '#D36086', + }, + { + key: 'uniqueDestinationIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 }, + { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 }, + { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 }, + ], + color: '#9170B8', + }, + ], + barChart: [ + { key: 'uniqueSourceIps', value: [{ x: 'uniqueSourceIps', y: '1714' }], color: '#D36086' }, + { + key: 'uniqueDestinationIps', + value: [{ x: 'uniqueDestinationIps', y: 2354 }], + color: '#9170B8', + }, + ], + description: 'UNIQUE_PRIVATE_IPS', + enableAreaChart: true, + enableBarChart: true, + fields: [ + { + key: 'uniqueSourceIps', + description: 'Source', + value: 1714, + color: '#D36086', + icon: 'cross', + }, + { + key: 'uniqueDestinationIps', + description: 'Dest.', + value: 2359, + color: '#9170B8', + icon: 'cross', + }, + ], + }; + + let wrapper: ReactWrapper; describe('rendering kpis with charts', () => { - const mockStatItemsData: StatItemsProps = { - areaChart: [ - { - key: 'uniqueSourceIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 }, - { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 }, - { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 }, - ], - color: '#D36086', - }, - { - key: 'uniqueDestinationIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 }, - { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 }, - { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 }, - ], - color: '#9170B8', - }, - ], - barChart: [ - { key: 'uniqueSourceIps', value: [{ x: 'uniqueSourceIps', y: '1714' }], color: '#D36086' }, - { - key: 'uniqueDestinationIps', - value: [{ x: 'uniqueDestinationIps', y: 2354 }], - color: '#9170B8', - }, - ], - description: 'UNIQUE_PRIVATE_IPS', - enableAreaChart: true, - enableBarChart: true, - fields: [ - { - key: 'uniqueSourceIps', - description: 'Source', - value: 1714, - color: '#D36086', - icon: 'cross', - }, - { - key: 'uniqueDestinationIps', - description: 'Dest.', - value: 2359, - color: '#9170B8', - icon: 'cross', - }, - ], - from, - id: 'statItems', - key: 'mock-keys', - to, - narrowDateRange: mockNarrowDateRange, - }; - let wrapper: ReactWrapper; beforeAll(() => { wrapper = mount( @@ -202,6 +201,43 @@ describe('Stat Items Component', () => { expect(wrapper.find(EuiHorizontalRule)).toHaveLength(1); }); }); + describe('Toggle query', () => { + test('toggleQuery updates toggleStatus', () => { + wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="query-toggle-stat"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockSetQuerySkip).toBeCalledWith(true); + }); + test('toggleStatus=true, render all', () => { + wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="stat-title"]').first().exists()).toEqual(true); + }); + test('toggleStatus=false, render none', () => { + jest + .spyOn(module, 'useQueryToggle') + .mockImplementation(() => ({ toggleStatus: false, setToggleStatus: mockSetToggle })); + wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toEqual( + false + ); + expect(wrapper.find('[data-test-subj="stat-title"]').first().exists()).toEqual(false); + }); + }); }); describe('addValueToFields', () => { @@ -244,7 +280,9 @@ describe('useKpiMatrixStatus', () => { 'statItem', from, to, - mockNarrowDateRange + mockNarrowDateRange, + mockSetQuerySkip, + false ); return ( @@ -262,8 +300,10 @@ describe('useKpiMatrixStatus', () => { ); - - expect(wrapper.find('MockChildComponent').get(0).props).toEqual(mockEnableChartsData); + const result = { ...wrapper.find('MockChildComponent').get(0).props }; + const { setQuerySkip, ...restResult } = result; + const { setQuerySkip: a, ...restExpect } = mockEnableChartsData; + expect(restResult).toEqual(restExpect); }); test('it should not append areaChart if enableAreaChart is off', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx index 424920d34e2e8..6de3cc07472bc 100644 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx @@ -12,13 +12,16 @@ import { EuiPanel, EuiHorizontalRule, EuiIcon, + EuiButtonIcon, + EuiLoadingSpinner, EuiTitle, IconType, } from '@elastic/eui'; import { get, getOr } from 'lodash/fp'; -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; +import { useQueryToggle } from '../../containers/query_toggle'; import { HostsKpiStrategyResponse, @@ -34,6 +37,7 @@ import { InspectButton } from '../inspect'; import { VisualizationActions, HISTOGRAM_ACTIONS_BUTTON_CLASS } from '../visualization_actions'; import { HoverVisibilityContainer } from '../hover_visibility_container'; import { LensAttributes } from '../visualization_actions/types'; +import * as i18n from '../../containers/query_toggle/translations'; import { UserskKpiStrategyResponse } from '../../../../common/search_strategy/security_solution/users'; const FlexItem = styled(EuiFlexItem)` @@ -84,6 +88,8 @@ export interface StatItemsProps extends StatItems { narrowDateRange: UpdateDateRange; to: string; showInspectButton?: boolean; + loading: boolean; + setQuerySkip: (skip: boolean) => void; } export const numberFormatter = (value: string | number): string => value.toLocaleString(); @@ -176,33 +182,27 @@ export const useKpiMatrixStatus = ( id: string, from: string, to: string, - narrowDateRange: UpdateDateRange -): StatItemsProps[] => { - const [statItemsProps, setStatItemsProps] = useState(mappings as StatItemsProps[]); - - useEffect(() => { - setStatItemsProps( - mappings.map((stat) => { - return { - ...stat, - areaChart: stat.enableAreaChart ? addValueToAreaChart(stat.fields, data) : undefined, - barChart: stat.enableBarChart ? addValueToBarChart(stat.fields, data) : undefined, - fields: addValueToFields(stat.fields, data), - id, - key: `kpi-summary-${stat.key}`, - statKey: `${stat.key}`, - from, - to, - narrowDateRange, - }; - }) - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data]); - - return statItemsProps; -}; - + narrowDateRange: UpdateDateRange, + setQuerySkip: (skip: boolean) => void, + loading: boolean +): StatItemsProps[] => + mappings.map((stat) => ({ + ...stat, + areaChart: stat.enableAreaChart ? addValueToAreaChart(stat.fields, data) : undefined, + barChart: stat.enableBarChart ? addValueToBarChart(stat.fields, data) : undefined, + fields: addValueToFields(stat.fields, data), + id, + key: `kpi-summary-${stat.key}`, + statKey: `${stat.key}`, + from, + to, + narrowDateRange, + setQuerySkip, + loading, + })); +const StyledTitle = styled.h6` + line-height: 200%; +`; export const StatItemsComponent = React.memo( ({ areaChart, @@ -214,13 +214,15 @@ export const StatItemsComponent = React.memo( from, grow, id, - showInspectButton, + loading = false, + showInspectButton = true, index, narrowDateRange, statKey = 'item', to, barChartLensAttributes, areaChartLensAttributes, + setQuerySkip, }) => { const isBarChartDataAvailable = barChart && @@ -239,101 +241,143 @@ export const StatItemsComponent = React.memo( [from, to] ); + const { toggleStatus, setToggleStatus } = useQueryToggle(id); + + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const toggle = useCallback(() => toggleQuery(!toggleStatus), [toggleQuery, toggleStatus]); + return ( - -
{description}
-
+ + + + + + + {description} + + +
- {showInspectButton && ( + {showInspectButton && toggleStatus && !loading && ( )}
+ {loading && ( + + + + + + )} + {toggleStatus && !loading && ( + <> + + {fields.map((field) => ( + + + {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && ( + + + + )} - - {fields.map((field) => ( - - - {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && ( - - - - )} + + + +

+ {field.value != null + ? field.value.toLocaleString() + : getEmptyTagValue()}{' '} + {field.description} +

+
+ {field.lensAttributes && timerange && ( + + )} +
+
+
+
+ ))} +
+ {(enableAreaChart || enableBarChart) && } + + {enableBarChart && ( - - -

- {field.value != null ? field.value.toLocaleString() : getEmptyTagValue()}{' '} - {field.description} -

-
- {field.lensAttributes && timerange && ( - - )} -
+
-
-
- ))} -
+ )} - {(enableAreaChart || enableBarChart) && } - - {enableBarChart && ( - - - - )} - - {enableAreaChart && from != null && to != null && ( - <> - - - - - )} - + {enableAreaChart && from != null && to != null && ( + <> + + + + + )} + + + )}
); @@ -344,6 +388,8 @@ export const StatItemsComponent = React.memo( prevProps.enableBarChart === nextProps.enableBarChart && prevProps.from === nextProps.from && prevProps.grow === nextProps.grow && + prevProps.loading === nextProps.loading && + prevProps.setQuerySkip === nextProps.setQuerySkip && prevProps.id === nextProps.id && prevProps.index === nextProps.index && prevProps.narrowDateRange === nextProps.narrowDateRange && diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts index d8a2db30d4a7e..3b319b810a66e 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts @@ -31,6 +31,7 @@ export type UrlStateType = | 'cases' | 'detection_response' | 'exceptions' + | 'get_started' | 'host' | 'users' | 'network' diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts index 559dff64eec4b..e5ce8e4105cac 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts @@ -94,6 +94,9 @@ export const replaceQueryStringInLocation = ( export const getUrlType = (pageName: string): UrlStateType => { if (pageName === SecurityPageName.overview) { return 'overview'; + } + if (pageName === SecurityPageName.landing) { + return 'get_started'; } else if (pageName === SecurityPageName.hosts) { return 'host'; } else if (pageName === SecurityPageName.network) { diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts index e09dbe23d512a..138fa99ef4074 100644 --- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts @@ -6,7 +6,6 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; - import { useKibana } from '../../../common/lib/kibana'; import { useMatrixHistogram, useMatrixHistogramCombined } from '.'; import { MatrixHistogramType } from '../../../../common/search_strategy'; @@ -39,6 +38,7 @@ describe('useMatrixHistogram', () => { indexNames: [], stackByField: 'event.module', startDate: new Date(Date.now()).toISOString(), + skip: false, }; afterEach(() => { @@ -145,6 +145,17 @@ describe('useMatrixHistogram', () => { mockDnsSearchStrategyResponse.rawResponse.aggregations?.dns_name_query_count.buckets ); }); + + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { ...props }; + const { rerender } = renderHook(() => useMatrixHistogram(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(3); + }); }); describe('useMatrixHistogramCombined', () => { diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts index c49a9d0438b2d..f6670c98fc0ee 100644 --- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts +++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts @@ -229,6 +229,14 @@ export const useMatrixHistogram = ({ }; }, [matrixHistogramRequest, hostsSearch, skip]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + const runMatrixHistogramSearch = useCallback( (to: string, from: string) => { hostsSearch({ diff --git a/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx new file mode 100644 index 0000000000000..76f1c02dcb43c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + renderHook, + act, + RenderResult, + WaitForNextUpdate, + cleanup, +} from '@testing-library/react-hooks'; +import { QueryToggle, useQueryToggle } from '.'; +import { RouteSpyState } from '../../utils/route/types'; +import { SecurityPageName } from '../../../../common/constants'; +import { useKibana } from '../../lib/kibana'; + +const mockRouteSpy: RouteSpyState = { + pageName: SecurityPageName.overview, + detailName: undefined, + tabName: undefined, + search: '', + pathName: '/', +}; +jest.mock('../../lib/kibana'); +jest.mock('../../utils/route/use_route_spy', () => ({ + useRouteSpy: () => [mockRouteSpy], +})); + +describe('useQueryToggle', () => { + let result: RenderResult; + let waitForNextUpdate: WaitForNextUpdate; + const mockSet = jest.fn(); + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + storage: { + get: () => true, + set: mockSet, + }, + }, + }); + }); + beforeEach(() => { + jest.clearAllMocks(); + }); + it('Toggles local storage', async () => { + await act(async () => { + ({ result, waitForNextUpdate } = renderHook(() => useQueryToggle('queryId'))); + await waitForNextUpdate(); + expect(result.current.toggleStatus).toEqual(true); + }); + act(() => { + result.current.setToggleStatus(false); + }); + expect(result.current.toggleStatus).toEqual(false); + expect(mockSet).toBeCalledWith('kibana.siem:queryId.query.toggle:overview', false); + cleanup(); + }); + it('null storage key, do not set', async () => { + await act(async () => { + ({ result, waitForNextUpdate } = renderHook(() => useQueryToggle())); + await waitForNextUpdate(); + expect(result.current.toggleStatus).toEqual(true); + }); + act(() => { + result.current.setToggleStatus(false); + }); + expect(mockSet).not.toBeCalled(); + cleanup(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx new file mode 100644 index 0000000000000..53bcd6b60fc1b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx @@ -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 { useEffect, useCallback, useState } from 'react'; +import { useKibana } from '../../lib/kibana'; +import { useRouteSpy } from '../../utils/route/use_route_spy'; + +export const getUniqueStorageKey = (pageName: string, id?: string): string | null => + id && pageName.length > 0 ? `kibana.siem:${id}.query.toggle:${pageName}` : null; +export interface QueryToggle { + toggleStatus: boolean; + setToggleStatus: (b: boolean) => void; +} + +export const useQueryToggle = (id?: string): QueryToggle => { + const [{ pageName }] = useRouteSpy(); + const { + services: { storage }, + } = useKibana(); + const storageKey = getUniqueStorageKey(pageName, id); + + const [storageValue, setStorageValue] = useState( + storageKey != null ? storage.get(storageKey) ?? true : true + ); + + useEffect(() => { + if (storageKey != null) { + setStorageValue(storage.get(storageKey) ?? true); + } + }, [storage, storageKey]); + + const setToggleStatus = useCallback( + (isOpen: boolean) => { + if (storageKey != null) { + storage.set(storageKey, isOpen); + setStorageValue(isOpen); + } + }, + [storage, storageKey] + ); + + return id + ? { + toggleStatus: storageValue, + setToggleStatus, + } + : { + toggleStatus: true, + setToggleStatus: () => {}, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx b/x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx new file mode 100644 index 0000000000000..acb64e7e6b510 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const QUERY_BUTTON_TITLE = (buttonOn: boolean) => + buttonOn + ? i18n.translate('xpack.securitySolution.toggleQuery.on', { + defaultMessage: 'Open', + }) + : i18n.translate('xpack.securitySolution.toggleQuery.off', { + defaultMessage: 'Closed', + }); diff --git a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts index 5bfa9028a0fe8..c1513b7a0485b 100644 --- a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts @@ -6,7 +6,7 @@ */ import { useSearchStrategy } from './index'; -import { renderHook } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-hooks'; import { useObservable } from '@kbn/securitysolution-hook-utils'; import { FactoryQueryTypes } from '../../../../common/search_strategy'; @@ -200,4 +200,19 @@ describe('useSearchStrategy', () => { expect(start).toBeCalledWith(expect.objectContaining({ signal })); }); + it('skip = true will cancel any running request', () => { + const abortSpy = jest.fn(); + const signal = new AbortController().signal; + jest.spyOn(window, 'AbortController').mockReturnValue({ abort: abortSpy, signal }); + const factoryQueryType = 'fakeQueryType' as FactoryQueryTypes; + const localProps = { + ...userSearchStrategyProps, + skip: false, + factoryQueryType, + }; + const { rerender } = renderHook(() => useSearchStrategy(localProps)); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx index 77676a83d39b6..234cf039024ba 100644 --- a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx @@ -96,6 +96,7 @@ export const useSearchStrategy = ({ factoryQueryType, initialResult, errorMessage, + skip = false, }: { factoryQueryType: QueryType; /** @@ -106,6 +107,7 @@ export const useSearchStrategy = ({ * Message displayed to the user on a Toast when an erro happens. */ errorMessage?: string; + skip?: boolean; }) => { const abortCtrl = useRef(new AbortController()); const { getTransformChangesIfTheyExist } = useTransforms(); @@ -154,6 +156,12 @@ export const useSearchStrategy = ({ }; }, []); + useEffect(() => { + if (skip) { + abortCtrl.current.abort(); + } + }, [skip]); + const [formatedResult, inspect] = useMemo( () => [ result diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 7795e76c5fbbb..52f0b1a682097 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -203,7 +203,6 @@ export const mockGlobalState: State = { [usersModel.UsersTableType.allUsers]: { activePage: 0, limit: 10, - // TODO sort: { field: RiskScoreFields.riskScore, direction: Direction.desc }, }, [usersModel.UsersTableType.anomalies]: null, [usersModel.UsersTableType.risk]: { @@ -215,11 +214,15 @@ export const mockGlobalState: State = { }, severitySelection: [], }, + [usersModel.UsersTableType.events]: { activePage: 0, limit: 10 }, + [usersModel.UsersTableType.alerts]: { activePage: 0, limit: 10 }, }, }, details: { queries: { [usersModel.UsersTableType.anomalies]: null, + [usersModel.UsersTableType.events]: { activePage: 0, limit: 10 }, + [usersModel.UsersTableType.alerts]: { activePage: 0, limit: 10 }, }, }, }, diff --git a/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts b/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts index e04d059a515d4..bfd844caad1b4 100644 --- a/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts +++ b/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts @@ -13,7 +13,7 @@ import { EntryNested, NestedEntriesArray, } from '@kbn/securitysolution-io-ts-list-types'; -import { ConditionEntryField, EntryTypes } from '@kbn/securitysolution-utils'; +import { AllConditionEntryFields, EntryFieldType, EntryTypes } from '@kbn/securitysolution-utils'; import { ConditionEntriesMap, ConditionEntry } from '../../../../common/endpoint/types'; @@ -46,12 +46,12 @@ const createEntryNested = (field: string, entries: NestedEntriesArray): EntryNes return { field, entries, type: 'nested' }; }; -function groupHashEntry(conditionEntry: ConditionEntry): EntriesArray { +function groupHashEntry(prefix: 'process' | 'file', conditionEntry: ConditionEntry): EntriesArray { const entriesArray: EntriesArray = []; if (!Array.isArray(conditionEntry.value)) { const entry = createEntryMatch( - `process.hash.${hashType(conditionEntry.value)}`, + `${prefix}${EntryFieldType.HASH}${hashType(conditionEntry.value)}`, conditionEntry.value.toLowerCase() ); entriesArray.push(entry); @@ -80,7 +80,7 @@ function groupHashEntry(conditionEntry: ConditionEntry): EntriesArray { return; } - const entry = createEntryMatchAny(`process.hash.${type}`, values); + const entry = createEntryMatchAny(`${prefix}${EntryFieldType.HASH}${type}`, values); entriesArray.push(entry); }); @@ -88,6 +88,7 @@ function groupHashEntry(conditionEntry: ConditionEntry): EntriesArray { } function createNestedSignatureEntry( + field: AllConditionEntryFields, value: string | string[], isTrustedApp: boolean = false ): EntryNested { @@ -97,19 +98,23 @@ function createNestedSignatureEntry( const nestedEntries: EntryNested['entries'] = []; if (isTrustedApp) nestedEntries.push(createEntryMatch('trusted', 'true')); nestedEntries.push(subjectNameMatch); - return createEntryNested('process.Ext.code_signature', nestedEntries); + return createEntryNested(field, nestedEntries); } -function createWildcardPathEntry(value: string | string[]): EntryMatchWildcard | EntryMatchAny { +function createWildcardPathEntry( + field: AllConditionEntryFields, + value: string | string[] +): EntryMatchWildcard | EntryMatchAny { return Array.isArray(value) - ? createEntryMatchAny('process.executable.caseless', value) - : createEntryMatchWildcard('process.executable.caseless', value); + ? createEntryMatchAny(field, value) + : createEntryMatchWildcard(field, value); } -function createPathEntry(value: string | string[]): EntryMatch | EntryMatchAny { - return Array.isArray(value) - ? createEntryMatchAny('process.executable.caseless', value) - : createEntryMatch('process.executable.caseless', value); +function createPathEntry( + field: AllConditionEntryFields, + value: string | string[] +): EntryMatch | EntryMatchAny { + return Array.isArray(value) ? createEntryMatchAny(field, value) : createEntryMatch(field, value); } export const conditionEntriesToEntries = ( @@ -119,19 +124,25 @@ export const conditionEntriesToEntries = ( const entriesArray: EntriesArray = []; conditionEntries.forEach((conditionEntry) => { - if (conditionEntry.field === ConditionEntryField.HASH) { - groupHashEntry(conditionEntry).forEach((entry) => entriesArray.push(entry)); - } else if (conditionEntry.field === ConditionEntryField.SIGNER) { - const entry = createNestedSignatureEntry(conditionEntry.value, isTrustedApp); + if (conditionEntry.field.includes(EntryFieldType.HASH)) { + const prefix = conditionEntry.field.split('.')[0] as 'process' | 'file'; + groupHashEntry(prefix, conditionEntry).forEach((entry) => entriesArray.push(entry)); + } else if (conditionEntry.field.includes(EntryFieldType.SIGNER)) { + const entry = createNestedSignatureEntry( + conditionEntry.field, + conditionEntry.value, + isTrustedApp + ); entriesArray.push(entry); } else if ( - conditionEntry.field === ConditionEntryField.PATH && + (conditionEntry.field.includes(EntryFieldType.EXECUTABLE) || + conditionEntry.field.includes(EntryFieldType.PATH)) && conditionEntry.type === 'wildcard' ) { - const entry = createWildcardPathEntry(conditionEntry.value); + const entry = createWildcardPathEntry(conditionEntry.field, conditionEntry.value); entriesArray.push(entry); } else { - const entry = createPathEntry(conditionEntry.value); + const entry = createPathEntry(conditionEntry.field, conditionEntry.value); entriesArray.push(entry); } }); @@ -140,49 +151,51 @@ export const conditionEntriesToEntries = ( }; const createConditionEntry = ( - field: ConditionEntryField, + field: AllConditionEntryFields, type: EntryTypes, value: string | string[] ): ConditionEntry => { return { field, value, type, operator: OPERATOR_VALUE }; }; +function createWildcardHashField( + field: string +): Extract { + const prefix = field.split('.')[0] as 'process' | 'file'; + return `${prefix}${EntryFieldType.HASH}*`; +} + export const entriesToConditionEntriesMap = ( entries: EntriesArray ): ConditionEntriesMap => { return entries.reduce((memo: ConditionEntriesMap, entry) => { - if (entry.field.startsWith('process.hash') && entry.type === 'match') { + const field = entry.field as AllConditionEntryFields; + if (field.includes(EntryFieldType.HASH) && entry.type === 'match') { + const wildcardHashField = createWildcardHashField(field); return { ...memo, - [ConditionEntryField.HASH]: createConditionEntry( - ConditionEntryField.HASH, - entry.type, - entry.value - ), + [wildcardHashField]: createConditionEntry(wildcardHashField, entry.type, entry.value), } as ConditionEntriesMap; - } else if (entry.field.startsWith('process.hash') && entry.type === 'match_any') { - const currentValues = (memo[ConditionEntryField.HASH]?.value as string[]) ?? []; + } else if (field.includes(EntryFieldType.HASH) && entry.type === 'match_any') { + const wildcardHashField = createWildcardHashField(field); + const currentValues = (memo[wildcardHashField]?.value as string[]) ?? []; return { ...memo, - [ConditionEntryField.HASH]: createConditionEntry(ConditionEntryField.HASH, entry.type, [ + [wildcardHashField]: createConditionEntry(wildcardHashField, entry.type, [ ...currentValues, ...entry.value, ]), } as ConditionEntriesMap; } else if ( - entry.field === ConditionEntryField.PATH && + (field.includes(EntryFieldType.EXECUTABLE) || field.includes(EntryFieldType.PATH)) && (entry.type === 'match' || entry.type === 'match_any' || entry.type === 'wildcard') ) { return { ...memo, - [ConditionEntryField.PATH]: createConditionEntry( - ConditionEntryField.PATH, - entry.type, - entry.value - ), + [field]: createConditionEntry(field, entry.type, entry.value), } as ConditionEntriesMap; - } else if (entry.field === ConditionEntryField.SIGNER && entry.type === 'nested') { + } else if (field.includes(EntryFieldType.SIGNER) && entry.type === 'nested') { const subjectNameCondition = entry.entries.find((subEntry): subEntry is EntryMatch => { return ( subEntry.field === 'subject_name' && @@ -193,8 +206,8 @@ export const entriesToConditionEntriesMap = { const actual = jest.requireActual('react-router-dom'); return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; @@ -22,6 +24,12 @@ describe('AlertsCountPanel', () => { const defaultProps = { signalIndexName: 'signalIndexName', }; + const mockSetToggle = jest.fn(); + const mockUseQueryToggle = useQueryToggle as jest.Mock; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + }); it('renders correctly', async () => { await act(async () => { @@ -54,4 +62,38 @@ describe('AlertsCountPanel', () => { }); }); }); + describe('toggleQuery', () => { + it('toggles', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + }); + }); + it('toggleStatus=true, render', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toEqual(true); + }); + }); + it('toggleStatus=false, hide', async () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toEqual(false); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx index 04b8f482fd121..1c0e2144ad9d4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx @@ -6,7 +6,7 @@ */ import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; -import React, { memo, useMemo, useState, useEffect } from 'react'; +import React, { memo, useMemo, useState, useEffect, useCallback } from 'react'; import uuid from 'uuid'; import type { Filter, Query } from '@kbn/es-query'; @@ -24,6 +24,7 @@ import type { AlertsCountAggregation } from './types'; import { DEFAULT_STACK_BY_FIELD } from '../common/config'; import { KpiPanel, StackByComboBox } from '../common/components'; import { useInspectButton } from '../common/hooks'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count'; @@ -64,6 +65,20 @@ export const AlertsCountPanel = memo( } }, [query, filters]); + const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_ALERTS_COUNT_ID); + const [querySkip, setQuerySkip] = useState(!toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const { loading: isLoadingAlerts, data: alertsData, @@ -80,6 +95,7 @@ export const AlertsCountPanel = memo( runtimeMappings ), indexName: signalIndexName, + skip: querySkip, }); useEffect(() => { @@ -99,21 +115,26 @@ export const AlertsCountPanel = memo( }); return ( - - + + - + {toggleStatus && ( + + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx index 29e18a1c49c12..3135e2e173793 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx @@ -12,9 +12,13 @@ import { mount } from 'enzyme'; import type { Filter } from '@kbn/es-query'; import { TestProviders } from '../../../../common/mock'; import { SecurityPageName } from '../../../../app/types'; +import { MatrixLoader } from '../../../../common/components/matrix_histogram/matrix_loader'; import { AlertsHistogramPanel } from './index'; import * as helpers from './helpers'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; + +jest.mock('../../../../common/containers/query_toggle'); jest.mock('react-router-dom', () => { const originalModule = jest.requireActual('react-router-dom'); @@ -91,6 +95,12 @@ describe('AlertsHistogramPanel', () => { updateDateRange: jest.fn(), }; + const mockSetToggle = jest.fn(); + const mockUseQueryToggle = useQueryToggle as jest.Mock; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + }); + afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); @@ -339,4 +349,40 @@ describe('AlertsHistogramPanel', () => { `); }); }); + + describe('toggleQuery', () => { + it('toggles', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + }); + }); + it('toggleStatus=true, render', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(MatrixLoader).exists()).toEqual(true); + }); + }); + it('toggleStatus=false, hide', async () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find(MatrixLoader).exists()).toEqual(false); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx index 571f656389f6a..84476c3ee6885 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx @@ -45,6 +45,7 @@ import type { AlertsStackByField } from '../common/types'; import { KpiPanel, StackByComboBox } from '../common/components'; import { useInspectButton } from '../common/hooks'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; const defaultTotalAlertsObj: AlertsTotal = { value: 0, @@ -116,6 +117,19 @@ export const AlertsHistogramPanel = memo( onlyField == null ? defaultStackByOption : onlyField ); + const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_HISTOGRAM_ID); + const [querySkip, setQuerySkip] = useState(!toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); const { loading: isLoadingAlerts, data: alertsData, @@ -132,6 +146,7 @@ export const AlertsHistogramPanel = memo( runtimeMappings ), indexName: signalIndexName, + skip: querySkip, }); const kibana = useKibana(); @@ -270,17 +285,21 @@ export const AlertsHistogramPanel = memo( ); return ( - + ( - {isInitialLoading ? ( - - ) : ( - - )} + {toggleStatus ? ( + isInitialLoading ? ( + + ) : ( + + ) + ) : null} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx index 6a56f7bc220ac..27f33409ae1a5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx @@ -12,17 +12,23 @@ import { PANEL_HEIGHT, MOBILE_PANEL_HEIGHT } from './config'; import { useStackByFields } from './hooks'; import * as i18n from './translations'; -export const KpiPanel = styled(EuiPanel)<{ height?: number }>` +export const KpiPanel = styled(EuiPanel)<{ height?: number; $toggleStatus: boolean }>` display: flex; flex-direction: column; position: relative; overflow: hidden; - - height: ${MOBILE_PANEL_HEIGHT}px; - @media only screen and (min-width: ${(props) => props.theme.eui.euiBreakpoints.m}) { + ${({ $toggleStatus }) => + $toggleStatus && + ` height: ${PANEL_HEIGHT}px; + `} } + ${({ $toggleStatus }) => + $toggleStatus && + ` + height: ${MOBILE_PANEL_HEIGHT}px; + `} `; interface StackedBySelectProps { selected: string; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx index 277e2008601dc..5ed7a219e5068 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx @@ -129,4 +129,22 @@ describe('useQueryAlerts', () => { }); }); }); + + test('skip', async () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + await act(async () => { + const localProps = { query: mockAlertsQuery, indexName, skip: false }; + const { rerender, waitForNextUpdate } = renderHook< + [object, string], + ReturnQueryAlerts + >(() => useQueryAlerts(localProps)); + await waitForNextUpdate(); + await waitForNextUpdate(); + + localProps.skip = true; + act(() => rerender()); + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx index b2bbcdf277992..2b98987e52675 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx @@ -94,6 +94,12 @@ export const useQueryAlerts = ({ if (!isEmpty(query) && !skip) { fetchData(); } + if (skip) { + setLoading(false); + isSubscribed = false; + abortCtrl.abort(); + } + return () => { isSubscribed = false; abortCtrl.abort(); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index 65684a7c7d9de..72984a8bcbe92 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -40,6 +40,7 @@ import { userHasPermissions } from '../../helpers'; import { useListsConfig } from '../../../../../containers/detection_engine/lists/use_lists_config'; import { ExceptionsTableItem } from './types'; import { MissingPrivilegesCallOut } from '../../../../../components/callouts/missing_privileges_callout'; +import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../../../../../common/endpoint/service/artifacts/constants'; export type Func = () => Promise; @@ -84,9 +85,7 @@ export const ExceptionListsTable = React.memo(() => { http, namespaceTypes: ['single', 'agnostic'], notifications, - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ALL_ENDPOINT_ARTIFACT_LIST_IDS, }); const [loadingTableInfo, exceptionListsWithRuleRefs, exceptionsListsRef] = useAllExceptionLists({ exceptionLists: exceptions ?? [], diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap index ed119568cdcb3..bffd5e2261ad9 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap @@ -105,6 +105,7 @@ exports[`Authentication Table Component rendering it renders the authentication isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={54} type="page" diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx index 14dc1769dbd05..2ec333e335639 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx @@ -45,6 +45,7 @@ describe('Authentication Table Component', () => { isInspect={false} loading={false} loadPage={loadPage} + setQuerySkip={jest.fn()} showMorePagesIndicator={getOr( false, 'showMorePagesIndicator', diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx index 4402f6a210947..2bbda82e15315 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx @@ -43,6 +43,7 @@ interface AuthenticationTableProps { loadPage: (newActivePage: number) => void; id: string; isInspect: boolean; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: hostsModel.HostsType; @@ -78,6 +79,7 @@ const AuthenticationTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -133,6 +135,7 @@ const AuthenticationTableComponent: React.FC = ({ loading={loading} loadPage={loadPage} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} totalCount={fakeTotalCount} updateLimitPagination={updateLimitPagination} diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx index e4130eee21909..f4da6983fc590 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx @@ -54,6 +54,7 @@ interface HostRiskScoreTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; severityCount: SeverityCount; totalCount: number; type: hostsModel.HostsType; @@ -71,6 +72,7 @@ const HostRiskScoreTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, severityCount, totalCount, type, @@ -207,6 +209,7 @@ const HostRiskScoreTableComponent: React.FC = ({ loadPage={loadPage} onChange={onSort} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={false} sorting={sort} split={true} diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap index 59a00cbf190f6..f646fc12c4697 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap @@ -36,6 +36,7 @@ exports[`Hosts Table rendering it renders the default Hosts table 1`] = ` isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={false} totalCount={-1} type="page" diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx index 71efbb0a44d15..43dc31c68d1bc 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx @@ -69,6 +69,7 @@ describe('Hosts Table', () => { fakeTotalCount={0} loading={false} loadPage={loadPage} + setQuerySkip={jest.fn()} showMorePagesIndicator={false} totalCount={-1} type={hostsModel.HostsType.page} @@ -91,6 +92,7 @@ describe('Hosts Table', () => { data={mockData} totalCount={0} fakeTotalCount={-1} + setQuerySkip={jest.fn()} showMorePagesIndicator={false} loadPage={loadPage} type={hostsModel.HostsType.page} @@ -113,6 +115,7 @@ describe('Hosts Table', () => { data={mockData} totalCount={0} fakeTotalCount={-1} + setQuerySkip={jest.fn()} showMorePagesIndicator={false} loadPage={loadPage} type={hostsModel.HostsType.page} @@ -136,6 +139,7 @@ describe('Hosts Table', () => { data={mockData} totalCount={0} fakeTotalCount={-1} + setQuerySkip={jest.fn()} showMorePagesIndicator={false} loadPage={loadPage} type={hostsModel.HostsType.page} diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx index 01306004844d8..42c8254ffd183 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx @@ -42,6 +42,7 @@ interface HostsTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: hostsModel.HostsType; @@ -77,6 +78,7 @@ const HostsTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -172,6 +174,7 @@ const HostsTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={sorting} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx new file mode 100644 index 0000000000000..164b88399bbe9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx @@ -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 { useHostsKpiAuthentications } from '../../../containers/kpi_hosts/authentications'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { HostsKpiAuthentications } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_hosts/authentications'); +jest.mock('../common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Authentications KPI', () => { + const mockUseHostsKpiAuthentications = useHostsKpiAuthentications as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseHostsKpiAuthentications.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseHostsKpiAuthentications.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseHostsKpiAuthentications.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx index 1158c842e04cb..f12eca88ffc95 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx @@ -5,17 +5,18 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiUserAuthenticationsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_area'; import { kpiUserAuthenticationsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_bar'; import { kpiUserAuthenticationsMetricSuccessLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_metric_success'; import { kpiUserAuthenticationsMetricFailureLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentication_metric_failure'; -import { useHostsKpiAuthentications } from '../../../containers/kpi_hosts/authentications'; +import { useHostsKpiAuthentications, ID } from '../../../containers/kpi_hosts/authentications'; import { KpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -57,12 +58,17 @@ const HostsKpiAuthenticationsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useHostsKpiAuthentications({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -77,6 +83,7 @@ const HostsKpiAuthenticationsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx index e3460ec22e73e..4296ae4984b95 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiFlexItem, EuiLoadingSpinner, EuiFlexGroup } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -42,10 +42,11 @@ interface KpiBaseComponentProps { from: string; to: string; narrowDateRange: UpdateDateRange; + setQuerySkip: (skip: boolean) => void; } export const KpiBaseComponent = React.memo( - ({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange }) => { + ({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange, setQuerySkip }) => { const { cases } = useKibana().services; const CasesContext = cases.ui.getCasesContext(); const userPermissions = useGetUserCasesPermissions(); @@ -57,13 +58,11 @@ export const KpiBaseComponent = React.memo( id, from, to, - narrowDateRange + narrowDateRange, + setQuerySkip, + loading ); - if (loading) { - return ; - } - return ( @@ -87,11 +86,3 @@ export const KpiBaseComponent = React.memo( KpiBaseComponent.displayName = 'KpiBaseComponent'; export const KpiBaseComponentManage = manageQuery(KpiBaseComponent); - -export const KpiBaseComponentLoader: React.FC = () => ( - - - - - -); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.test.tsx new file mode 100644 index 0000000000000..49b6986515564 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.test.tsx @@ -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 { useHostsKpiHosts } from '../../../containers/kpi_hosts/hosts'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { HostsKpiHosts } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_hosts/hosts'); +jest.mock('../common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Hosts KPI', () => { + const mockUseHostsKpiHosts = useHostsKpiHosts as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseHostsKpiHosts.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseHostsKpiHosts.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseHostsKpiHosts.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx index 79118b66a3f71..b29bdddd44e35 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiHostAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_area'; import { kpiHostMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric'; -import { useHostsKpiHosts } from '../../../containers/kpi_hosts/hosts'; +import { useHostsKpiHosts, ID } from '../../../containers/kpi_hosts/hosts'; import { KpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -42,12 +43,17 @@ const HostsKpiHostsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useHostsKpiHosts({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -62,6 +68,7 @@ const HostsKpiHostsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx index f515490252d40..0a86a9006b637 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx @@ -11,6 +11,7 @@ import { EuiHorizontalRule, EuiIcon, EuiPanel, + EuiLoadingSpinner, EuiTitle, EuiText, } from '@elastic/eui'; @@ -22,7 +23,6 @@ import { BUTTON_CLASS as INPECT_BUTTON_CLASS, } from '../../../../common/components/inspect'; -import { KpiBaseComponentLoader } from '../common'; import * as i18n from './translations'; import { useInspectQuery } from '../../../../common/hooks/use_inspect_query'; @@ -36,6 +36,13 @@ import { HoverVisibilityContainer } from '../../../../common/components/hover_vi import { KpiRiskScoreStrategyResponse, RiskSeverity } from '../../../../../common/search_strategy'; import { RiskScore } from '../../../../common/components/severity/common'; +const KpiBaseComponentLoader: React.FC = () => ( + + + + + +); const QUERY_ID = 'hostsKpiRiskyHostsQuery'; const HostCount = styled(EuiText)` diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.test.tsx new file mode 100644 index 0000000000000..20de5db340b5e --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.test.tsx @@ -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 { useHostsKpiUniqueIps } from '../../../containers/kpi_hosts/unique_ips'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { HostsKpiUniqueIps } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_hosts/unique_ips'); +jest.mock('../common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Unique IPs KPI', () => { + const mockUseHostsKpiUniqueIps = useHostsKpiUniqueIps as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseHostsKpiUniqueIps.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseHostsKpiUniqueIps.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseHostsKpiUniqueIps.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx index ef7bdfa1dc031..ef032d041db7d 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx @@ -5,17 +5,18 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiUniqueIpsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_area'; import { kpiUniqueIpsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_bar'; import { kpiUniqueIpsDestinationMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_destination_metric'; import { kpiUniqueIpsSourceMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_source_metric'; -import { useHostsKpiUniqueIps } from '../../../containers/kpi_hosts/unique_ips'; +import { useHostsKpiUniqueIps, ID } from '../../../containers/kpi_hosts/unique_ips'; import { KpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -57,12 +58,17 @@ const HostsKpiUniqueIpsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useHostsKpiUniqueIps({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -77,6 +83,7 @@ const HostsKpiUniqueIpsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx index 2f3a414344cfc..5ff8696ae5be3 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx @@ -5,16 +5,30 @@ * 2.0. */ -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import React from 'react'; import { TopHostScoreContributors } from '.'; import { TestProviders } from '../../../common/mock'; import { useHostRiskScore } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +jest.mock('../../../common/containers/query_toggle'); jest.mock('../../../risk_score/containers'); const useHostRiskScoreMock = useHostRiskScore as jest.Mock; - +const testProps = { + setQuery: jest.fn(), + deleteQuery: jest.fn(), + hostName: 'test-host-name', + from: '2020-07-07T08:20:18.966Z', + to: '2020-07-08T08:20:18.966Z', +}; describe('Host Risk Flyout', () => { + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockSetToggle = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + }); it('renders', () => { useHostRiskScoreMock.mockReturnValueOnce([ true, @@ -26,13 +40,7 @@ describe('Host Risk Flyout', () => { const { queryByTestId } = render( - + ); @@ -69,13 +77,7 @@ describe('Host Risk Flyout', () => { const { queryAllByRole } = render( - + ); @@ -83,4 +85,66 @@ describe('Host Risk Flyout', () => { expect(queryAllByRole('row')[2]).toHaveTextContent('second'); expect(queryAllByRole('row')[3]).toHaveTextContent('third'); }); + + describe('toggleQuery', () => { + beforeEach(() => { + useHostRiskScoreMock.mockReturnValue([ + true, + { + data: [], + isModuleEnabled: true, + }, + ]); + }); + + test('toggleQuery updates toggleStatus', () => { + const { getByTestId } = render( + + + + ); + expect(useHostRiskScoreMock.mock.calls[0][0].skip).toEqual(false); + fireEvent.click(getByTestId('query-toggle-header')); + expect(mockSetToggle).toBeCalledWith(false); + expect(useHostRiskScoreMock.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(useHostRiskScoreMock.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + const { queryByTestId } = render( + + + + ); + expect(queryByTestId('topHostScoreContributors-table')).toBeTruthy(); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const { queryByTestId } = render( + + + + ); + expect(queryByTestId('topHostScoreContributors-table')).toBeFalsy(); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + render( + + + + ); + expect(useHostRiskScoreMock.mock.calls[0][0].skip).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx index 8811a6b64e7fc..a3b7022ee83ef 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiFlexGroup, @@ -27,6 +27,7 @@ import { HostsComponentsQueryProps } from '../../pages/navigation/types'; import { RuleLink } from '../../../detections/pages/detection_engine/rules/all/use_columns'; import { HostRiskScoreQueryId, useHostRiskScore } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; export interface TopHostScoreContributorsProps extends Pick { @@ -77,11 +78,27 @@ const TopHostScoreContributorsComponent: React.FC const sort = useMemo(() => ({ field: RiskScoreFields.timestamp, direction: Direction.desc }), []); + const { toggleStatus, setToggleStatus } = useQueryToggle(QUERY_ID); + const [querySkip, setQuerySkip] = useState(!toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, { data, refetch, inspect }] = useHostRiskScore({ filterQuery: hostName ? buildHostNamesFilter([hostName]) : undefined, timerange, onlyLatest: false, sort, + skip: querySkip, pagination: { querySize: 1, cursorStart: 0, @@ -119,24 +136,37 @@ const TopHostScoreContributorsComponent: React.FC - - - - - - - - - - - + {toggleStatus && ( + + + + )} + + {toggleStatus && ( + + + + + + )} ); diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap index a93c4062e8808..19a6018f6b680 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap @@ -205,6 +205,7 @@ exports[`Uncommon Process Table Component rendering it renders the default Uncom isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={5} type="page" diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx index 29d3f110e8181..300abc60818cb 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx @@ -36,21 +36,24 @@ describe('Uncommon Process Table Component', () => { const loadPage = jest.fn(); const mount = useMountAppended(); + const defaultProps = { + data: mockData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo), + id: 'uncommonProcess', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo), + totalCount: mockData.totalCount, + type: hostsModel.HostsType.page, + }; + describe('rendering', () => { test('it renders the default Uncommon process table', () => { const wrapper = shallow( - + ); @@ -60,17 +63,7 @@ describe('Uncommon Process Table Component', () => { test('it has a double dash (empty value) without any hosts at all', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiTableRow').at(0).find('.euiTableRowCell').at(3).text()).toBe( @@ -81,17 +74,7 @@ describe('Uncommon Process Table Component', () => { test('it has a single host without any extra comma when the number of hosts is exactly 1', () => { const wrapper = mount( - + ); @@ -103,17 +86,7 @@ describe('Uncommon Process Table Component', () => { test('it has a single link when the number of hosts is exactly 1', () => { const wrapper = mount( - + ); @@ -125,17 +98,7 @@ describe('Uncommon Process Table Component', () => { test('it has a comma separated list of hosts when the number of hosts is greater than 1', () => { const wrapper = mount( - + ); @@ -147,17 +110,7 @@ describe('Uncommon Process Table Component', () => { test('it has 2 links when the number of hosts is equal to 2', () => { const wrapper = mount( - + ); @@ -169,17 +122,7 @@ describe('Uncommon Process Table Component', () => { test('it is empty when all hosts are invalid because they do not contain an id and a name', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiTableRow').at(3).find('.euiTableRowCell').at(3).text()).toBe( @@ -190,17 +133,7 @@ describe('Uncommon Process Table Component', () => { test('it has no link when all hosts are invalid because they do not contain an id and a name', () => { const wrapper = mount( - + ); expect( @@ -211,17 +144,7 @@ describe('Uncommon Process Table Component', () => { test('it is returns two hosts when others are invalid because they do not contain an id and a name', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiTableRow').at(4).find('.euiTableRowCell').at(3).text()).toBe( diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx index 0af27bdb0ba18..cbdae1747e5f6 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx @@ -30,6 +30,7 @@ interface UncommonProcessTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: hostsModel.HostsType; @@ -72,6 +73,7 @@ const UncommonProcessTableComponent = React.memo( loading, loadPage, totalCount, + setQuerySkip, showMorePagesIndicator, type, }) => { @@ -125,6 +127,7 @@ const UncommonProcessTableComponent = React.memo( loading={loading} loadPage={loadPage} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} totalCount={fakeTotalCount} updateLimitPagination={updateLimitPagination} diff --git a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.test.tsx new file mode 100644 index 0000000000000..1f6ee4cb276ec --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.test.tsx @@ -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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useAuthentications } from './index'; +import { HostsType } from '../../store/model'; + +describe('authentications', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: HostsType.page, + skip: false, + }; + const { rerender } = renderHook(() => useAuthentications(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx index f446380e54937..1ff27e4b29917 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx @@ -36,7 +36,7 @@ import * as i18n from './translations'; import { useTransforms } from '../../../transforms/containers/use_transforms'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'hostsAuthenticationsQuery'; +export const ID = 'hostsAuthenticationsQuery'; export interface AuthenticationArgs { authentications: AuthenticationsEdges[]; @@ -215,5 +215,13 @@ export const useAuthentications = ({ }; }, [authenticationsRequest, authenticationsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, authenticationsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx new file mode 100644 index 0000000000000..df64f4cd6f81a --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx @@ -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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useAllHost } from './index'; +import { HostsType } from '../../store/model'; + +describe('useAllHost', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: HostsType.page, + skip: false, + }; + const { rerender } = renderHook(() => useAllHost(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx index 1a9e86755cf7d..c4259e8a5a737 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx @@ -217,5 +217,13 @@ export const useAllHost = ({ }; }, [hostsRequest, hostsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, hostsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.test.tsx new file mode 100644 index 0000000000000..f62fc3a77786e --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useHostsKpiAuthentications } from './index'; + +describe('kpi hosts - authentications', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useHostsKpiAuthentications(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx index c15c68d246f14..9fa38c14e2ea4 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx @@ -26,7 +26,7 @@ import * as i18n from './translations'; import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'hostsKpiAuthenticationsQuery'; +export const ID = 'hostsKpiAuthenticationsQuery'; export interface HostsKpiAuthenticationsArgs extends Omit { @@ -165,5 +165,13 @@ export const useHostsKpiAuthentications = ({ }; }, [hostsKpiAuthenticationsRequest, hostsKpiAuthenticationsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, hostsKpiAuthenticationsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.test.tsx new file mode 100644 index 0000000000000..f12b92f0661bc --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useHostsKpiHosts } from './index'; + +describe('kpi hosts - hosts', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useHostsKpiHosts(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx index fdce4dfe79591..63f0476c2b631 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx @@ -26,7 +26,7 @@ import * as i18n from './translations'; import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'hostsKpiHostsQuery'; +export const ID = 'hostsKpiHostsQuery'; export interface HostsKpiHostsArgs extends Omit { id: string; @@ -155,5 +155,13 @@ export const useHostsKpiHosts = ({ }; }, [hostsKpiHostsRequest, hostsKpiHostsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, hostsKpiHostsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.test.tsx new file mode 100644 index 0000000000000..ec8c73ad1d6a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useHostsKpiUniqueIps } from './index'; + +describe('kpi hosts - Unique Ips', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useHostsKpiUniqueIps(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx index 5b9eeb2710ff3..25a9f76daf40f 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx @@ -26,7 +26,7 @@ import * as i18n from './translations'; import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'hostsKpiUniqueIpsQuery'; +export const ID = 'hostsKpiUniqueIpsQuery'; export interface HostsKpiUniqueIpsArgs extends Omit { @@ -163,5 +163,13 @@ export const useHostsKpiUniqueIps = ({ }; }, [hostsKpiUniqueIpsRequest, hostsKpiUniqueIpsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, hostsKpiUniqueIpsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.test.tsx new file mode 100644 index 0000000000000..e334465fdbc1c --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.test.tsx @@ -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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useUncommonProcesses } from './index'; +import { HostsType } from '../../store/model'; + +describe('useUncommonProcesses', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: HostsType.page, + skip: false, + }; + const { rerender } = renderHook(() => useUncommonProcesses(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx index 9548027520bd1..d196c4ea01af1 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx @@ -34,7 +34,7 @@ import { InspectResponse } from '../../../types'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'hostsUncommonProcessesQuery'; +export const ID = 'hostsUncommonProcessesQuery'; export interface UncommonProcessesArgs { id: string; @@ -202,5 +202,13 @@ export const useUncommonProcesses = ({ }; }, [uncommonProcessesRequest, uncommonProcessesSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, uncommonProcessesResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx index 891db470161d4..142f3b922f842 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx @@ -15,6 +15,7 @@ import { HostsTableType } from '../../store/model'; import { AnomaliesQueryTabBody } from '../../../common/containers/anomalies/anomalies_query_tab_body'; import { useGlobalTime } from '../../../common/containers/use_global_time'; import { AnomaliesHostTable } from '../../../common/components/ml/tables/anomalies_host_table'; +import { EventsQueryTabBody } from '../../../common/components/events_tab/events_query_tab_body'; import { HostDetailsTabsProps } from './types'; import { type } from './utils'; @@ -23,10 +24,10 @@ import { HostsQueryTabBody, AuthenticationsQueryTabBody, UncommonProcessQueryTabBody, - EventsQueryTabBody, HostAlertsQueryTabBody, HostRiskTabBody, } from '../navigation'; +import { TimelineId } from '../../../../common/types'; export const HostDetailsTabs = React.memo( ({ @@ -98,7 +99,11 @@ export const HostDetailsTabs = React.memo( - + diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx index 86dae3780e1ae..d82189ab1e3bb 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx @@ -25,6 +25,8 @@ import { Hosts } from './hosts'; import { HostsTabs } from './hosts_tabs'; import { useSourcererDataView } from '../../common/containers/sourcerer'; import { mockCasesContract } from '../../../../cases/public/mocks'; +import { APP_UI_ID, SecurityPageName } from '../../../common/constants'; +import { getAppLandingUrl } from '../../common/components/link_to/redirect_to_overview'; jest.mock('../../common/containers/sourcerer'); @@ -39,7 +41,7 @@ jest.mock('../../common/components/query_bar', () => ({ jest.mock('../../common/components/visualization_actions', () => ({ VisualizationActions: jest.fn(() =>
), })); - +const mockNavigateToApp = jest.fn(); jest.mock('../../common/lib/kibana', () => { const original = jest.requireActual('../../common/lib/kibana'); @@ -48,6 +50,10 @@ jest.mock('../../common/lib/kibana', () => { useKibana: () => ({ services: { ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + navigateToApp: mockNavigateToApp, + }, cases: { ...mockCasesContract(), }, @@ -79,19 +85,25 @@ const mockHistory = { }; const mockUseSourcererDataView = useSourcererDataView as jest.Mock; describe('Hosts - rendering', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test('it renders the Setup Instructions text when no index is available', async () => { mockUseSourcererDataView.mockReturnValue({ indicesExist: false, }); - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); }); test('it DOES NOT render the Setup Instructions text when an index is available', async () => { @@ -99,14 +111,14 @@ describe('Hosts - rendering', () => { indicesExist: true, indexPattern: {}, }); - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); + expect(mockNavigateToApp).not.toHaveBeenCalled(); }); test('it should render tab navigation', async () => { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx index 07979c289309a..d7c615c08ec28 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx @@ -15,15 +15,17 @@ import { HostsTableType } from '../store/model'; import { AnomaliesQueryTabBody } from '../../common/containers/anomalies/anomalies_query_tab_body'; import { AnomaliesHostTable } from '../../common/components/ml/tables/anomalies_host_table'; import { UpdateDateRange } from '../../common/components/charts/common'; +import { EventsQueryTabBody } from '../../common/components/events_tab/events_query_tab_body'; import { HOSTS_PATH } from '../../../common/constants'; + import { HostsQueryTabBody, HostRiskScoreQueryTabBody, AuthenticationsQueryTabBody, UncommonProcessQueryTabBody, - EventsQueryTabBody, } from './navigation'; import { HostAlertsQueryTabBody } from './navigation/alerts_query_tab_body'; +import { TimelineId } from '../../../common/types'; export const HostsTabs = memo( ({ @@ -96,7 +98,7 @@ export const HostsTabs = memo( - + diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.test.tsx new file mode 100644 index 0000000000000..9d31b477a851a --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useAuthentications } from '../../containers/authentications'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { AuthenticationsQueryTabBody } from './authentications_query_tab_body'; +import { HostsType } from '../../store/model'; + +jest.mock('../../containers/authentications'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('Authentications query tab body', () => { + const mockUseAuthentications = useAuthentications as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseAuthentications.mockReturnValue([ + false, + { + authentications: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx index 879f0fce02fd5..1096085b93016 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx @@ -6,7 +6,7 @@ */ import { getOr } from 'lodash/fp'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { AuthenticationTable } from '../../components/authentications_table'; import { manageQuery } from '../../../common/components/page/manage_query'; import { useAuthentications } from '../../containers/authentications'; @@ -22,6 +22,7 @@ import * as i18n from '../translations'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { authenticationLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/hosts/authentication'; import { LensAttributes } from '../../../common/components/visualization_actions/types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const AuthenticationTableManage = manageQuery(AuthenticationTable); @@ -76,6 +77,11 @@ const AuthenticationsQueryTabBodyComponent: React.FC startDate, type, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { authentications, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, @@ -84,7 +90,7 @@ const AuthenticationsQueryTabBodyComponent: React.FC endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type, }); @@ -119,6 +125,7 @@ const AuthenticationsQueryTabBodyComponent: React.FC loading={loading} loadPage={loadPage} refetch={refetch} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} setQuery={setQuery} totalCount={totalCount} diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.test.tsx new file mode 100644 index 0000000000000..8b3a05cc3d88c --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useHostRiskScore, useHostRiskScoreKpi } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { HostRiskScoreQueryTabBody } from './host_risk_score_tab_body'; +import { HostsType } from '../../store/model'; + +jest.mock('../../../risk_score/containers'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('Host risk score query tab body', () => { + const mockUseHostRiskScore = useHostRiskScore as jest.Mock; + const mockUseHostRiskScoreKpi = useHostRiskScoreKpi as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseHostRiskScoreKpi.mockReturnValue({ + loading: false, + severityCount: { + unknown: 12, + low: 12, + moderate: 12, + high: 12, + critical: 12, + }, + }); + mockUseHostRiskScore.mockReturnValue([ + false, + { + hosts: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseHostRiskScore.mock.calls[0][0].skip).toEqual(false); + expect(mockUseHostRiskScoreKpi.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseHostRiskScore.mock.calls[0][0].skip).toEqual(true); + expect(mockUseHostRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx index 11a422fa0cd3d..11ba8d154cd81 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { noop } from 'lodash/fp'; import { HostsComponentsQueryProps } from './types'; import { manageQuery } from '../../../common/components/page/manage_query'; @@ -18,6 +18,7 @@ import { useHostRiskScore, useHostRiskScoreKpi, } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const HostRiskScoreTableManage = manageQuery(HostRiskScoreTable); @@ -43,15 +44,22 @@ export const HostRiskScoreQueryTabBody = ({ [activePage, limit] ); + const { toggleStatus } = useQueryToggle(HostRiskScoreQueryId.HOSTS_BY_RISK); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + const [loading, { data, totalCount, inspect, isInspected, refetch }] = useHostRiskScore({ filterQuery, - skip, + skip: querySkip, pagination, sort, }); const { severityCount, loading: isKpiLoading } = useHostRiskScoreKpi({ filterQuery, + skip: querySkip, }); return ( @@ -65,6 +73,7 @@ export const HostRiskScoreQueryTabBody = ({ loadPage={noop} // It isn't necessary because PaginatedTable updates redux store and we load the page when activePage updates on the store refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} severityCount={severityCount} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.test.tsx new file mode 100644 index 0000000000000..487934f30e8d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useAllHost } from '../../containers/hosts'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { HostsQueryTabBody } from './hosts_query_tab_body'; +import { HostsType } from '../../store/model'; + +jest.mock('../../containers/hosts'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('Hosts query tab body', () => { + const mockUseAllHost = useAllHost as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseAllHost.mockReturnValue([ + false, + { + hosts: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseAllHost.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseAllHost.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx index cc43cfed4619d..b72e6572849d1 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx @@ -6,11 +6,12 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; -import { useAllHost } from '../../containers/hosts'; +import React, { useEffect, useState } from 'react'; +import { useAllHost, ID } from '../../containers/hosts'; import { HostsComponentsQueryProps } from './types'; import { HostsTable } from '../../components/hosts_table'; import { manageQuery } from '../../../common/components/page/manage_query'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const HostsTableManage = manageQuery(HostsTable); @@ -25,8 +26,21 @@ export const HostsQueryTabBody = ({ startDate, type, }: HostsComponentsQueryProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { hosts, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }] = - useAllHost({ docValueFields, endDate, filterQuery, indexNames, skip, startDate, type }); + useAllHost({ + docValueFields, + endDate, + filterQuery, + indexNames, + skip: querySkip, + startDate, + type, + }); return ( { + const mockUseUncommonProcesses = useUncommonProcesses as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseUncommonProcesses.mockReturnValue([ + false, + { + uncommonProcesses: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseUncommonProcesses.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseUncommonProcesses.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx index 236b732a5af05..f6957fedd83c5 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx @@ -6,11 +6,12 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; -import { useUncommonProcesses } from '../../containers/uncommon_processes'; +import React, { useEffect, useState } from 'react'; +import { useUncommonProcesses, ID } from '../../containers/uncommon_processes'; import { HostsComponentsQueryProps } from './types'; import { UncommonProcessTable } from '../../components/uncommon_process_table'; import { manageQuery } from '../../../common/components/page/manage_query'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const UncommonProcessTableManage = manageQuery(UncommonProcessTable); @@ -25,6 +26,11 @@ export const UncommonProcessQueryTabBody = ({ startDate, type, }: HostsComponentsQueryProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { uncommonProcesses, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, @@ -33,7 +39,7 @@ export const UncommonProcessQueryTabBody = ({ endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type, }); @@ -49,6 +55,7 @@ export const UncommonProcessQueryTabBody = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx new file mode 100644 index 0000000000000..8ff4b71668fd4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx @@ -0,0 +1,39 @@ +/* + * 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, { memo, PropsWithChildren } from 'react'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { UserCommandInput } from './user_command_input'; +import { ParsedCommandInput } from '../service/parsed_command_input'; +import { CommandDefinition } from '../types'; +import { CommandInputUsage } from './command_usage'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; + +export type BadArgumentProps = PropsWithChildren<{ + parsedInput: ParsedCommandInput; + commandDefinition: CommandDefinition; +}>; + +export const BadArgument = memo( + ({ parsedInput, commandDefinition, children = null }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + return ( + <> + + + + + {children} + + + + ); + } +); +BadArgument.displayName = 'BadArgument'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx new file mode 100644 index 0000000000000..2205bb38d0aea --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiCallOut } from '@elastic/eui'; + +export interface CommandExecutionFailureProps { + error: Error; +} +export const CommandExecutionFailure = memo(({ error }) => { + return {error}; +}); +CommandExecutionFailure.displayName = 'CommandExecutionOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx new file mode 100644 index 0000000000000..8bb9769980914 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx @@ -0,0 +1,107 @@ +/* + * 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, { memo, ReactNode, useCallback, useEffect, useState } from 'react'; +import { EuiButton, EuiLoadingChart } from '@elastic/eui'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { CommandExecutionFailure } from './command_execution_failure'; +import { UserCommandInput } from './user_command_input'; +import { Command } from '../types'; +import { useCommandService } from '../hooks/state_selectors/use_command_service'; +import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch'; + +const CommandOutputContainer = styled.div` + position: relative; + + .run-in-background { + position: absolute; + right: 0; + top: 1em; + } +`; + +export interface CommandExecutionOutputProps { + command: Command; +} +export const CommandExecutionOutput = memo(({ command }) => { + const commandService = useCommandService(); + const [isRunning, setIsRunning] = useState(true); + const [output, setOutput] = useState(null); + const dispatch = useConsoleStateDispatch(); + + // FIXME:PT implement the `run in the background` functionality + const [showRunInBackground, setShowRunInTheBackground] = useState(false); + const handleRunInBackgroundClick = useCallback(() => { + setShowRunInTheBackground(false); + }, []); + + useEffect(() => { + (async () => { + const timeoutId = setTimeout(() => { + setShowRunInTheBackground(true); + }, 15000); + + try { + const commandOutput = await commandService.executeCommand(command); + setOutput(commandOutput.result); + + // FIXME: PT the console should scroll the bottom as well + } catch (error) { + setOutput(); + } + + clearTimeout(timeoutId); + setIsRunning(false); + setShowRunInTheBackground(false); + })(); + }, [command, commandService]); + + useEffect(() => { + if (!isRunning) { + dispatch({ type: 'scrollDown' }); + } + }, [isRunning, dispatch]); + + return ( + + {showRunInBackground && ( +
+ + + +
+ )} +
+ + {isRunning && ( + <> + + + )} +
+
{output}
+
+ ); +}); +CommandExecutionOutput.displayName = 'CommandExecutionOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx new file mode 100644 index 0000000000000..e61318227cb1f --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConsoleProps } from '../../console'; +import { AppContextTestRender } from '../../../../../common/mock/endpoint'; +import { ConsoleTestSetup, getConsoleTestSetup } from '../../mocks'; + +describe('When entering data into the Console input', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + let enterCommand: ConsoleTestSetup['enterCommand']; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + ({ enterCommand } = testSetup); + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should display what the user is typing', () => { + render(); + + enterCommand('c', { inputOnly: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('c'); + + enterCommand('m', { inputOnly: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('cm'); + }); + + it('should delete last character when BACKSPACE is pressed', () => { + render(); + + enterCommand('cm', { inputOnly: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('cm'); + + enterCommand('{backspace}', { inputOnly: true, useKeyboard: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('c'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx new file mode 100644 index 0000000000000..f9b12391e6f6b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx @@ -0,0 +1,143 @@ +/* + * 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, { memo, MouseEventHandler, useCallback, useRef, useState } from 'react'; +import { CommonProps, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; +import classNames from 'classnames'; +import { KeyCapture, KeyCaptureProps } from './key_capture'; +import { useConsoleStateDispatch } from '../../hooks/state_selectors/use_console_state_dispatch'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj'; + +const CommandInputContainer = styled.div` + .prompt { + padding-right: 1ch; + } + + .textEntered { + white-space: break-spaces; + } + + .cursor { + display: inline-block; + width: 0.5em; + height: 1em; + background-color: ${({ theme }) => theme.eui.euiTextColors.default}; + + animation: cursor-blink-animation 1s steps(5, start) infinite; + -webkit-animation: cursor-blink-animation 1s steps(5, start) infinite; + @keyframes cursor-blink-animation { + to { + visibility: hidden; + } + } + @-webkit-keyframes cursor-blink-animation { + to { + visibility: hidden; + } + } + + &.inactive { + background-color: transparent !important; + } + } +`; + +export interface CommandInputProps extends CommonProps { + prompt?: string; + isWaiting?: boolean; + focusRef?: KeyCaptureProps['focusRef']; +} + +export const CommandInput = memo( + ({ prompt = '>', focusRef, ...commonProps }) => { + const dispatch = useConsoleStateDispatch(); + const [textEntered, setTextEntered] = useState(''); + const [isKeyInputBeingCaptured, setIsKeyInputBeingCaptured] = useState(false); + const _focusRef: KeyCaptureProps['focusRef'] = useRef(null); + const textDisplayRef = useRef(null); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + const keyCaptureFocusRef = focusRef || _focusRef; + + const handleKeyCaptureOnStateChange = useCallback< + NonNullable + >((isCapturing) => { + setIsKeyInputBeingCaptured(isCapturing); + }, []); + + const handleTypingAreaClick = useCallback( + (ev) => { + if (keyCaptureFocusRef.current) { + keyCaptureFocusRef.current(); + } + }, + [keyCaptureFocusRef] + ); + + const handleKeyCapture = useCallback( + ({ value, eventDetails }) => { + setTextEntered((prevState) => { + let updatedState = prevState + value; + + switch (eventDetails.keyCode) { + // BACKSPACE + // remove the last character from the text entered + case 8: + if (updatedState.length) { + updatedState = updatedState.replace(/.$/, ''); + } + break; + + // ENTER + // Execute command and blank out the input area + case 13: + dispatch({ type: 'executeCommand', payload: { input: updatedState } }); + return ''; + } + + return updatedState; + }); + }, + [dispatch] + ); + + return ( + + + + {prompt} + + + {textEntered} + + + + + + + + ); + } +); +CommandInput.displayName = 'CommandInput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts new file mode 100644 index 0000000000000..4db81ade86011 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CommandInput } from './command_input'; +export type { CommandInputProps } from './command_input'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx new file mode 100644 index 0000000000000..03bb133f88d79 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx @@ -0,0 +1,156 @@ +/* + * 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, { + FormEventHandler, + KeyboardEventHandler, + memo, + MutableRefObject, + useCallback, + useRef, + useState, +} from 'react'; +import { pick } from 'lodash'; +import styled from 'styled-components'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj'; + +const NOOP = () => undefined; + +const KeyCaptureContainer = styled.span` + display: inline-block; + position: relative; + width: 1px; + height: 1em; + overflow: hidden; + + .invisible-input { + &, + &:focus { + border: none; + background-image: none; + background-color: transparent; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + animation: none !important; + width: 1ch !important; + position: absolute; + left: -100px; + top: -100px; + } + } +`; + +export interface KeyCaptureProps { + onCapture: (params: { + value: string | undefined; + eventDetails: Pick< + KeyboardEvent, + 'key' | 'altKey' | 'ctrlKey' | 'keyCode' | 'metaKey' | 'repeat' | 'shiftKey' + >; + }) => void; + onStateChange?: (isCapturing: boolean) => void; + focusRef?: MutableRefObject<((force?: boolean) => void) | null>; +} + +/** + * Key Capture is an invisible INPUT field that we set focus to when the user clicks inside of + * the console. It's sole purpose is to capture what the user types, which is then pass along to be + * displayed in a more UX friendly way + */ +export const KeyCapture = memo(({ onCapture, focusRef, onStateChange }) => { + // We don't need the actual value that was last input in this component, because + // `setLastInput()` is used with a function that returns the typed character. + // This state is used like this: + // 1. user presses a keyboard key + // 2. `input` event is triggered - we store the letter typed + // 3. the next event to be triggered (after `input`) that we listen for is `keyup`, + // and when that is triggered, we take the input letter (already stored) and + // call `onCapture()` with it and then set the lastInput state back to an empty string + const [, setLastInput] = useState(''); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + const handleBlurAndFocus = useCallback( + (ev) => { + if (!onStateChange) { + return; + } + + onStateChange(ev.type === 'focus'); + }, + [onStateChange] + ); + + const handleOnKeyUp = useCallback>( + (ev) => { + ev.stopPropagation(); + + const eventDetails = pick(ev, [ + 'key', + 'altKey', + 'ctrlKey', + 'keyCode', + 'metaKey', + 'repeat', + 'shiftKey', + ]); + + setLastInput((value) => { + onCapture({ + value, + eventDetails, + }); + + return ''; + }); + }, + [onCapture] + ); + + const handleOnInput = useCallback>((ev) => { + const newValue = ev.currentTarget.value; + + setLastInput((prevState) => { + return `${prevState || ''}${newValue}`; + }); + }, []); + + const inputRef = useRef(null); + + const setFocus = useCallback((force: boolean = false) => { + // If user selected text and `force` is not true, then don't focus (else they lose selection) + if (!force && (window.getSelection()?.toString() ?? '').length > 0) { + return; + } + + inputRef.current?.focus(); + }, []); + + if (focusRef) { + focusRef.current = setFocus; + } + + // FIXME:PT probably need to add `aria-` type properties to the input? + return ( + + + + ); +}); +KeyCapture.displayName = 'KeyCapture'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx new file mode 100644 index 0000000000000..d7464e2f97391 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx @@ -0,0 +1,54 @@ +/* + * 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, { memo, useMemo } from 'react'; +import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { CommandDefinition } from '../types'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export interface CommandListProps { + commands: CommandDefinition[]; +} + +export const CommandList = memo(({ commands }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + const footerMessage = useMemo(() => { + return ( + {'some-command --help'}, + }} + /> + ); + }, []); + + return ( + <> + + {commands.map(({ name, about }) => { + return ( + + + + ); + })} + + {footerMessage} + + ); +}); +CommandList.displayName = 'CommandList'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx new file mode 100644 index 0000000000000..9d17d83f0266f --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { usageFromCommandDefinition } from '../service/usage_from_command_definition'; +import { CommandDefinition } from '../types'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export const CommandInputUsage = memo>(({ commandDef }) => { + const usageHelp = useMemo(() => { + return usageFromCommandDefinition(commandDef); + }, [commandDef]); + + return ( + + + + + + + + + + {usageHelp} + + + + + ); +}); +CommandInputUsage.displayName = 'CommandInputUsage'; + +export interface CommandUsageProps { + commandDef: CommandDefinition; +} + +export const CommandUsage = memo(({ commandDef }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + const hasArgs = useMemo(() => Object.keys(commandDef.args ?? []).length > 0, [commandDef.args]); + const commandOptions = useMemo(() => { + // `command.args` only here to silence TS check + if (!hasArgs || !commandDef.args) { + return []; + } + + return Object.entries(commandDef.args).map(([option, { about: description }]) => ({ + title: `--${option}`, + description, + })); + }, [commandDef.args, hasArgs]); + const additionalProps = useMemo( + () => ({ + className: 'euiTruncateText', + }), + [] + ); + + return ( + + {commandDef.about} + + {hasArgs && ( + <> + +

+ + + {commandDef.mustHaveArgs && commandDef.args && hasArgs && ( + + + + )} + +

+ {commandDef.args && ( + + )} + + )} +
+ ); +}); +CommandUsage.displayName = 'CommandUsage'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts new file mode 100644 index 0000000000000..8d7de159bbc5a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// FIXME:PT implement a React context to manage consoles diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx new file mode 100644 index 0000000000000..852b2b1ab58fe --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useReducer, memo, createContext, PropsWithChildren, useContext } from 'react'; +import { InitialStateInterface, initiateState, stateDataReducer } from './state_reducer'; +import { ConsoleStore } from './types'; + +const ConsoleStateContext = createContext(null); + +type ConsoleStateProviderProps = PropsWithChildren<{}> & InitialStateInterface; + +/** + * A Console wide data store for internal state management between inner components + */ +export const ConsoleStateProvider = memo( + ({ commandService, scrollToBottom, dataTestSubj, children }) => { + const [state, dispatch] = useReducer( + stateDataReducer, + { commandService, scrollToBottom, dataTestSubj }, + initiateState + ); + + // FIXME:PT should handle cases where props that are in the store change + // Probably need to have a `useAffect()` that just does a `dispatch()` to update those. + + return ( + + {children} + + ); + } +); +ConsoleStateProvider.displayName = 'ConsoleStateProvider'; + +export const useConsoleStore = (): ConsoleStore => { + const store = useContext(ConsoleStateContext); + + if (!store) { + throw new Error(`ConsoleStateContext not defined`); + } + + return store; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts new file mode 100644 index 0000000000000..dc59ac1c2acef --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ConsoleStateProvider } from './console_state'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts new file mode 100644 index 0000000000000..94175d9821ae7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConsoleDataState, ConsoleStoreReducer } from './types'; +import { handleExecuteCommand } from './state_update_handlers/handle_execute_command'; +import { ConsoleBuiltinCommandsService } from '../../service/builtin_command_service'; + +export type InitialStateInterface = Pick< + ConsoleDataState, + 'commandService' | 'scrollToBottom' | 'dataTestSubj' +>; + +export const initiateState = ({ + commandService, + scrollToBottom, + dataTestSubj, +}: InitialStateInterface): ConsoleDataState => { + return { + commandService, + scrollToBottom, + dataTestSubj, + commandHistory: [], + builtinCommandService: new ConsoleBuiltinCommandsService(), + }; +}; + +export const stateDataReducer: ConsoleStoreReducer = (state, action) => { + switch (action.type) { + case 'scrollDown': + state.scrollToBottom(); + return state; + + case 'executeCommand': + return handleExecuteCommand(state, action); + } + + return state; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx new file mode 100644 index 0000000000000..b6a8e4db52340 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx @@ -0,0 +1,192 @@ +/* + * 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 { ConsoleProps } from '../../../console'; +import { AppContextTestRender } from '../../../../../../common/mock/endpoint'; +import { getConsoleTestSetup } from '../../../mocks'; +import type { ConsoleTestSetup } from '../../../mocks'; +import { waitFor } from '@testing-library/react'; + +describe('When a Console command is entered by the user', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + let commandServiceMock: ConsoleTestSetup['commandServiceMock']; + let enterCommand: ConsoleTestSetup['enterCommand']; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + ({ commandServiceMock, enterCommand } = testSetup); + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should display all available commands when `help` command is entered', async () => { + render(); + enterCommand('help'); + + expect(renderResult.getByTestId('test-helpOutput')).toBeTruthy(); + + await waitFor(() => { + expect(renderResult.getAllByTestId('test-commandList-command')).toHaveLength( + // `+2` to account for builtin commands + commandServiceMock.getCommandList().length + 2 + ); + }); + }); + + it('should display custom help output when Command service has `getHelp()` defined', async () => { + commandServiceMock.getHelp = async () => { + return { + result:
{'help output'}
, + }; + }; + render(); + enterCommand('help'); + + await waitFor(() => { + expect(renderResult.getByTestId('custom-help')).toBeTruthy(); + }); + }); + + it('should clear the command output history when `clear` is entered', async () => { + render(); + enterCommand('help'); + enterCommand('help'); + + expect(renderResult.getByTestId('test-historyOutput').childElementCount).toBe(2); + + enterCommand('clear'); + + expect(renderResult.getByTestId('test-historyOutput').childElementCount).toBe(0); + }); + + it('should show individual command help when `--help` option is used', async () => { + render(); + enterCommand('cmd2 --help'); + + await waitFor(() => expect(renderResult.getByTestId('test-commandUsage')).toBeTruthy()); + }); + + it('should should custom command `--help` output when Command service defines `getCommandUsage()`', async () => { + commandServiceMock.getCommandUsage = async () => { + return { + result:
{'command help here'}
, + }; + }; + render(); + enterCommand('cmd2 --help'); + + await waitFor(() => expect(renderResult.getByTestId('cmd-help')).toBeTruthy()); + }); + + it('should execute a command entered', async () => { + render(); + enterCommand('cmd1'); + + await waitFor(() => { + expect(renderResult.getByTestId('exec-output')).toBeTruthy(); + }); + }); + + it('should allow multiple of the same options if `allowMultiples` is `true`', async () => { + render(); + enterCommand('cmd3 --foo one --foo two'); + + await waitFor(() => { + expect(renderResult.getByTestId('exec-output')).toBeTruthy(); + }); + }); + + it('should show error if unknown command', async () => { + render(); + enterCommand('foo-foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-unknownCommandError').textContent).toEqual( + 'Unknown commandFor a list of available command, enter: help' + ); + }); + }); + + it('should show error if options are used but command supports none', async () => { + render(); + enterCommand('cmd1 --foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'command does not support any argumentsUsage:cmd1' + ); + }); + }); + + it('should show error if unknown option is used', async () => { + render(); + enterCommand('cmd2 --file test --foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'unsupported argument: --fooUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error if any required option is not set', async () => { + render(); + enterCommand('cmd2 --ext one'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'missing required argument: --fileUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error if argument is used more than one', async () => { + render(); + enterCommand('cmd2 --file one --file two'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'argument can only be used once: --fileUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it("should show error returned by the option's `validate()` callback", async () => { + render(); + enterCommand('cmd2 --file one --bad foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'invalid argument value: --bad. This is a bad valueUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error no options were provided, bug command requires some', async () => { + render(); + enterCommand('cmd2'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'missing required arguments: --fileUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error if all arguments are optional, but at least 1 must be defined', async () => { + render(); + enterCommand('cmd4'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'at least one argument must be usedUsage:cmd4 [--foo --bar]' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx new file mode 100644 index 0000000000000..2815ec4605917 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint complexity: ["error", 40]*/ +// FIXME:PT remove the complexity + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { ConsoleDataAction, ConsoleDataState, ConsoleStoreReducer } from '../types'; +import { parseCommandInput } from '../../../service/parsed_command_input'; +import { HistoryItem } from '../../history_item'; +import { UnknownCommand } from '../../unknow_comand'; +import { HelpOutput } from '../../help_output'; +import { BadArgument } from '../../bad_argument'; +import { CommandExecutionOutput } from '../../command_execution_output'; +import { CommandDefinition } from '../../../types'; + +const toCliArgumentOption = (argName: string) => `--${argName}`; + +const getRequiredArguments = (argDefinitions: CommandDefinition['args']): string[] => { + if (!argDefinitions) { + return []; + } + + return Object.entries(argDefinitions) + .filter(([_, argDef]) => argDef.required) + .map(([argName]) => argName); +}; + +const updateStateWithNewCommandHistoryItem = ( + state: ConsoleDataState, + newHistoryItem: ConsoleDataState['commandHistory'][number] +): ConsoleDataState => { + return { + ...state, + commandHistory: [...state.commandHistory, newHistoryItem], + }; +}; + +export const handleExecuteCommand: ConsoleStoreReducer< + ConsoleDataAction & { type: 'executeCommand' } +> = (state, action) => { + const parsedInput = parseCommandInput(action.payload.input); + + if (parsedInput.name === '') { + return state; + } + + const { commandService, builtinCommandService } = state; + + // Is it an internal command? + if (builtinCommandService.isBuiltin(parsedInput.name)) { + const commandOutput = builtinCommandService.executeBuiltinCommand(parsedInput, commandService); + + if (commandOutput.clearBuffer) { + return { + ...state, + commandHistory: [], + }; + } + + return updateStateWithNewCommandHistoryItem(state, commandOutput.result); + } + + // ---------------------------------------------------- + // Validate and execute the user defined command + // ---------------------------------------------------- + const commandDefinition = commandService + .getCommandList() + .find((definition) => definition.name === parsedInput.name); + + // Unknown command + if (!commandDefinition) { + return updateStateWithNewCommandHistoryItem( + state, + + + + ); + } + + const requiredArgs = getRequiredArguments(commandDefinition.args); + + // If args were entered, then validate them + if (parsedInput.hasArgs()) { + // Show command help + if (parsedInput.hasArg('help')) { + return updateStateWithNewCommandHistoryItem( + state, + + + {(commandService.getCommandUsage || builtinCommandService.getCommandUsage)( + commandDefinition + )} + + + ); + } + + // Command supports no arguments + if (!commandDefinition.args || Object.keys(commandDefinition.args).length === 0) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.noArgumentsSupported', + { + defaultMessage: 'command does not support any arguments', + } + )} + + + ); + } + + // no unknown arguments allowed? + if (parsedInput.unknownArgs && parsedInput.unknownArgs.length) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.unknownArgument', { + defaultMessage: 'unknown argument(s): {unknownArgs}', + values: { + unknownArgs: parsedInput.unknownArgs.join(', '), + }, + })} + + + ); + } + + // Missing required Arguments + for (const requiredArg of requiredArgs) { + if (!parsedInput.args[requiredArg]) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.missingRequiredArg', + { + defaultMessage: 'missing required argument: {argName}', + values: { + argName: toCliArgumentOption(requiredArg), + }, + } + )} + + + ); + } + } + + // Validate each argument given to the command + for (const argName of Object.keys(parsedInput.args)) { + const argDefinition = commandDefinition.args[argName]; + const argInput = parsedInput.args[argName]; + + // Unknown argument + if (!argDefinition) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.unsupportedArg', { + defaultMessage: 'unsupported argument: {argName}', + values: { argName: toCliArgumentOption(argName) }, + })} + + + ); + } + + // does not allow multiple values + if ( + !argDefinition.allowMultiples && + Array.isArray(argInput.values) && + argInput.values.length > 0 + ) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce', + { + defaultMessage: 'argument can only be used once: {argName}', + values: { argName: toCliArgumentOption(argName) }, + } + )} + + + ); + } + + if (argDefinition.validate) { + const validationResult = argDefinition.validate(argInput); + + if (validationResult !== true) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.invalidArgValue', + { + defaultMessage: 'invalid argument value: {argName}. {error}', + values: { argName: toCliArgumentOption(argName), error: validationResult }, + } + )} + + + ); + } + } + } + } else if (requiredArgs.length > 0) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.mustHaveArgs', { + defaultMessage: 'missing required arguments: {requiredArgs}', + values: { + requiredArgs: requiredArgs.map((argName) => toCliArgumentOption(argName)).join(', '), + }, + })} + + + ); + } else if (commandDefinition.mustHaveArgs) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.oneArgIsRequired', { + defaultMessage: 'at least one argument must be used', + })} + + + ); + } + + // All is good. Execute the command + return updateStateWithNewCommandHistoryItem( + state, + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts new file mode 100644 index 0000000000000..72810d31e3248 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts @@ -0,0 +1,39 @@ +/* + * 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 { Dispatch, Reducer } from 'react'; +import { CommandServiceInterface } from '../../types'; +import { HistoryItemComponent } from '../history_item'; +import { BuiltinCommandServiceInterface } from '../../service/types.builtin_command_service'; + +export interface ConsoleDataState { + /** Command service defined on input to the `Console` component by consumers of the component */ + commandService: CommandServiceInterface; + /** Command service for builtin console commands */ + builtinCommandService: BuiltinCommandServiceInterface; + /** UI function that scrolls the console down to the bottom */ + scrollToBottom: () => void; + /** + * List of commands entered by the user and being shown in the UI + */ + commandHistory: Array>; + dataTestSubj?: string; +} + +export type ConsoleDataAction = + | { type: 'scrollDown' } + | { type: 'executeCommand'; payload: { input: string } }; + +export interface ConsoleStore { + state: ConsoleDataState; + dispatch: Dispatch; +} + +export type ConsoleStoreReducer = Reducer< + ConsoleDataState, + A +>; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx new file mode 100644 index 0000000000000..b0a2217e169c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, ReactNode, useEffect, useState } from 'react'; +import { EuiCallOut, EuiCallOutProps, EuiLoadingChart } from '@elastic/eui'; +import { UserCommandInput } from './user_command_input'; +import { CommandExecutionFailure } from './command_execution_failure'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export interface HelpOutputProps extends Pick { + input: string; + children: ReactNode | Promise<{ result: ReactNode }>; +} +export const HelpOutput = memo(({ input, children, ...euiCalloutProps }) => { + const [content, setContent] = useState(); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + useEffect(() => { + if (children instanceof Promise) { + (async () => { + try { + const response = await (children as Promise<{ + result: ReactNode; + }>); + setContent(response.result); + } catch (error) { + setContent(); + } + })(); + + return; + } + + setContent(children); + }, [children]); + + return ( +
+
+ +
+ + {content} + +
+ ); +}); +HelpOutput.displayName = 'HelpOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx new file mode 100644 index 0000000000000..0143d36f0e766 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx @@ -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 React, { memo, PropsWithChildren } from 'react'; +import { EuiFlexItem } from '@elastic/eui'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export type HistoryItemProps = PropsWithChildren<{}>; + +export const HistoryItem = memo(({ children }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + return ( + + {children} + + ); +}); + +HistoryItem.displayName = 'HistoryItem'; + +export type HistoryItemComponent = typeof HistoryItem; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx new file mode 100644 index 0000000000000..088a6fac57ae4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useEffect } from 'react'; +import { CommonProps, EuiFlexGroup } from '@elastic/eui'; +import { useCommandHistory } from '../hooks/state_selectors/use_command_history'; +import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export type OutputHistoryProps = CommonProps; + +export const HistoryOutput = memo((commonProps) => { + const historyItems = useCommandHistory(); + const dispatch = useConsoleStateDispatch(); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + // Anytime we add a new item to the history + // scroll down so that command input remains visible + useEffect(() => { + dispatch({ type: 'scrollDown' }); + }, [dispatch, historyItems.length]); + + return ( + + {historyItems} + + ); +}); + +HistoryOutput.displayName = 'HistoryOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx new file mode 100644 index 0000000000000..5529457cbb05a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { UserCommandInput } from './user_command_input'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; + +export interface UnknownCommand { + input: string; +} +export const UnknownCommand = memo(({ input }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + return ( + <> +
+ +
+ + + + + + {'help'}, + }} + /> + + + + ); +}); +UnknownCommand.displayName = 'UnknownCommand'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx new file mode 100644 index 0000000000000..84afff3f28209 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx @@ -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 React, { memo } from 'react'; + +export interface UserCommandInputProps { + input: string; +} + +export const UserCommandInput = memo(({ input }) => { + return ( + <> + {'$ '} + {input} + + ); +}); +UserCommandInput.displayName = 'UserCommandInput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx new file mode 100644 index 0000000000000..9adeaa72d683e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AppContextTestRender } from '../../../common/mock/endpoint'; +import { ConsoleProps } from './console'; +import { getConsoleTestSetup } from './mocks'; +import userEvent from '@testing-library/user-event'; + +describe('When using Console component', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should render console', () => { + render(); + + expect(renderResult.getByTestId('test')).toBeTruthy(); + }); + + it('should display prompt given on input', () => { + render({ prompt: 'MY PROMPT>>' }); + + expect(renderResult.getByTestId('test-cmdInput-prompt').textContent).toEqual('MY PROMPT>>'); + }); + + it('should focus on input area when it gains focus', () => { + render(); + userEvent.click(renderResult.getByTestId('test-mainPanel')); + + expect(document.activeElement!.classList.contains('invisible-input')).toBe(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/console.tsx b/x-pack/plugins/security_solution/public/management/components/console/console.tsx new file mode 100644 index 0000000000000..6c64a045c86fe --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/console.tsx @@ -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 React, { memo, useCallback, useRef } from 'react'; +import { CommonProps, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import styled from 'styled-components'; +import { HistoryOutput } from './components/history_output'; +import { CommandInput, CommandInputProps } from './components/command_input'; +import { CommandServiceInterface } from './types'; +import { ConsoleStateProvider } from './components/console_state'; +import { useTestIdGenerator } from '../hooks/use_test_id_generator'; + +// FIXME:PT implement dark mode for the console or light mode switch + +const ConsoleWindow = styled.div` + height: 100%; + + // FIXME: IMPORTANT - this should NOT be used in production + // dark mode on light theme / light mode on dark theme + filter: invert(100%); + + .ui-panel { + min-width: ${({ theme }) => theme.eui.euiBreakpoints.s}; + height: 100%; + min-height: 300px; + overflow-y: auto; + } + + .descriptionList-20_80 { + &.euiDescriptionList { + > .euiDescriptionList__title { + width: 20%; + } + + > .euiDescriptionList__description { + width: 80%; + } + } + } +`; + +export interface ConsoleProps extends CommonProps, Pick { + commandService: CommandServiceInterface; +} + +export const Console = memo(({ prompt, commandService, ...commonProps }) => { + const consoleWindowRef = useRef(null); + const inputFocusRef: CommandInputProps['focusRef'] = useRef(null); + const getTestId = useTestIdGenerator(commonProps['data-test-subj']); + + const scrollToBottom = useCallback(() => { + // We need the `setTimeout` here because in some cases, the command output + // will take a bit of time to populate its content due to the use of Promises + setTimeout(() => { + if (consoleWindowRef.current) { + consoleWindowRef.current.scrollTop = consoleWindowRef.current.scrollHeight; + } + }, 1); + + // NOTE: its IMPORTANT that this callback does NOT have any dependencies, because + // it is stored in State and currently not updated if it changes + }, []); + + const handleConsoleClick = useCallback(() => { + if (inputFocusRef.current) { + inputFocusRef.current(); + } + }, []); + + return ( + + + + + + + + + + + + + + + ); +}); + +Console.displayName = 'Console'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts new file mode 100644 index 0000000000000..22167d5066743 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; +import { CommandServiceInterface } from '../../types'; + +export const useCommandService = (): CommandServiceInterface => { + return useConsoleStore().state.builtinCommandService; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts new file mode 100644 index 0000000000000..ded51471a1c3e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; + +export const useCommandHistory = () => { + return useConsoleStore().state.commandHistory; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts new file mode 100644 index 0000000000000..66ce0c2b5eb43 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; +import { CommandServiceInterface } from '../../types'; + +export const useCommandService = (): CommandServiceInterface => { + return useConsoleStore().state.commandService; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts new file mode 100644 index 0000000000000..90e5fe094f9c7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; +import { ConsoleStore } from '../../components/console_state/types'; + +export const useConsoleStateDispatch = (): ConsoleStore['dispatch'] => { + return useConsoleStore().dispatch; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts new file mode 100644 index 0000000000000..144a5a63cd71b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; + +export const useDataTestSubj = (): string | undefined => { + return useConsoleStore().state.dataTestSubj; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/index.ts b/x-pack/plugins/security_solution/public/management/components/console/index.ts new file mode 100644 index 0000000000000..81244b3013b36 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { Console } from './console'; +export type { ConsoleProps } from './console'; +export type { CommandServiceInterface, CommandDefinition, Command } from './types'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx new file mode 100644 index 0000000000000..693daf83ed6ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import React from 'react'; +import { EuiCode } from '@elastic/eui'; +import userEvent from '@testing-library/user-event'; +import { act } from '@testing-library/react'; +import { Console } from './console'; +import type { ConsoleProps } from './console'; +import type { Command, CommandServiceInterface } from './types'; +import type { AppContextTestRender } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { CommandDefinition } from './types'; + +export interface ConsoleTestSetup { + renderConsole(props?: Partial): ReturnType; + + commandServiceMock: jest.Mocked; + + enterCommand( + cmd: string, + options?: Partial<{ + /** If true, the ENTER key will not be pressed */ + inputOnly: boolean; + /** + * if true, then the keyboard keys will be used to send the command. + * Use this if wanting ot press keyboard keys other than letter/punctuation + */ + useKeyboard: boolean; + }> + ): void; +} + +export const getConsoleTestSetup = (): ConsoleTestSetup => { + const mockedContext = createAppRootMockRenderer(); + + let renderResult: ReturnType; + + const commandServiceMock = getCommandServiceMock(); + + const renderConsole: ConsoleTestSetup['renderConsole'] = ({ + prompt = '$$>', + commandService = commandServiceMock, + 'data-test-subj': dataTestSubj = 'test', + ...others + } = {}) => { + if (commandService !== commandServiceMock) { + throw new Error('Must use CommandService provided by test setup'); + } + + return (renderResult = mockedContext.render( + + )); + }; + + const enterCommand: ConsoleTestSetup['enterCommand'] = ( + cmd, + { inputOnly = false, useKeyboard = false } = {} + ) => { + const keyCaptureInput = renderResult.getByTestId('test-keyCapture-input'); + + act(() => { + if (useKeyboard) { + userEvent.click(keyCaptureInput); + userEvent.keyboard(cmd); + } else { + userEvent.type(keyCaptureInput, cmd); + } + + if (!inputOnly) { + userEvent.keyboard('{enter}'); + } + }); + }; + + return { + renderConsole, + commandServiceMock, + enterCommand, + }; +}; + +export const getCommandServiceMock = (): jest.Mocked => { + return { + getCommandList: jest.fn(() => { + const commands: CommandDefinition[] = [ + { + name: 'cmd1', + about: 'a command with no options', + }, + { + name: 'cmd2', + about: 'runs cmd 2', + args: { + file: { + about: 'Includes file in the run', + required: true, + allowMultiples: false, + validate: () => { + return true; + }, + }, + ext: { + about: 'optional argument', + required: false, + allowMultiples: false, + }, + bad: { + about: 'will fail validation', + required: false, + allowMultiples: false, + validate: () => 'This is a bad value', + }, + }, + }, + { + name: 'cmd3', + about: 'allows argument to be used multiple times', + args: { + foo: { + about: 'foo stuff', + required: true, + allowMultiples: true, + }, + }, + }, + { + name: 'cmd4', + about: 'all options optinal, but at least one is required', + mustHaveArgs: true, + args: { + foo: { + about: 'foo stuff', + required: false, + allowMultiples: true, + }, + bar: { + about: 'bar stuff', + required: false, + allowMultiples: true, + }, + }, + }, + ]; + + return commands; + }), + + executeCommand: jest.fn(async (command: Command) => { + await new Promise((r) => setTimeout(r, 1)); + + return { + result: ( +
+
{`${command.commandDefinition.name}`}
+
{`command input: ${command.input}`}
+ + {JSON.stringify(command.args, null, 2)} + +
+ ), + }; + }), + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx new file mode 100644 index 0000000000000..6cd8af0dc6eff --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx @@ -0,0 +1,102 @@ +/* + * 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, { ReactNode } from 'react'; +import { i18n } from '@kbn/i18n'; +import { HistoryItem, HistoryItemComponent } from '../components/history_item'; +import { HelpOutput } from '../components/help_output'; +import { ParsedCommandInput } from './parsed_command_input'; +import { CommandList } from '../components/command_list'; +import { CommandUsage } from '../components/command_usage'; +import { Command, CommandDefinition, CommandServiceInterface } from '../types'; +import { BuiltinCommandServiceInterface } from './types.builtin_command_service'; + +const builtInCommands = (): CommandDefinition[] => { + return [ + { + name: 'help', + about: i18n.translate('xpack.securitySolution.console.builtInCommands.helpAbout', { + defaultMessage: 'View list of available commands', + }), + }, + { + name: 'clear', + about: i18n.translate('xpack.securitySolution.console.builtInCommands.clearAbout', { + defaultMessage: 'Clear the console buffer', + }), + }, + ]; +}; + +export class ConsoleBuiltinCommandsService implements BuiltinCommandServiceInterface { + constructor(private commandList = builtInCommands()) {} + + getCommandList(): CommandDefinition[] { + return this.commandList; + } + + async executeCommand(command: Command): Promise<{ result: ReactNode }> { + return { + result: null, + }; + } + + executeBuiltinCommand( + parsedInput: ParsedCommandInput, + contextConsoleService: CommandServiceInterface + ): { result: ReturnType | null; clearBuffer?: boolean } { + switch (parsedInput.name) { + case 'help': + return { + result: ( + + + {this.getHelpContent(parsedInput, contextConsoleService)} + + + ), + }; + + case 'clear': + return { + result: null, + clearBuffer: true, + }; + } + + return { result: null }; + } + + async getHelpContent( + parsedInput: ParsedCommandInput, + commandService: CommandServiceInterface + ): Promise<{ result: ReactNode }> { + let helpOutput: ReactNode; + + if (commandService.getHelp) { + helpOutput = (await commandService.getHelp()).result; + } else { + helpOutput = ( + + ); + } + + return { + result: helpOutput, + }; + } + + isBuiltin(name: string): boolean { + return !!this.commandList.find((command) => command.name === name); + } + + async getCommandUsage(command: CommandDefinition): Promise<{ result: ReactNode }> { + return { + result: , + }; + } +} diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts new file mode 100644 index 0000000000000..55e0b3dc6267b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts @@ -0,0 +1,95 @@ +/* + * 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. + */ + +// @ts-ignore +// eslint-disable-next-line import/no-extraneous-dependencies +import argsplit from 'argsplit'; + +// FIXME:PT use a 3rd party lib for arguments parsing +// For now, just using what I found in kibana package.json devDependencies, so this will NOT work for production + +// FIXME:PT Type `ParsedCommandInput` should be a generic that allows for the args's keys to be defined + +export interface ParsedArgData { + /** For arguments that were used only once. Will be `undefined` if multiples were used */ + value: undefined | string; + /** For arguments that were used multiple times */ + values: undefined | string[]; +} + +export interface ParsedCommandInput { + input: string; + name: string; + args: { + [argName: string]: ParsedArgData; + }; + unknownArgs: undefined | string[]; + hasArgs(): boolean; + hasArg(argName: string): boolean; +} + +const PARSED_COMMAND_INPUT_PROTOTYPE: Pick = Object.freeze({ + hasArgs(this: ParsedCommandInput) { + return Object.keys(this.args).length > 0 || Array.isArray(this.unknownArgs); + }, + + hasArg(argName: string): boolean { + // @ts-ignore + return Object.prototype.hasOwnProperty.call(this.args, argName); + }, +}); + +export const parseCommandInput = (input: string): ParsedCommandInput => { + const inputTokens: string[] = argsplit(input) || []; + const name: string = inputTokens.shift() || ''; + const args: ParsedCommandInput['args'] = {}; + let unknownArgs: ParsedCommandInput['unknownArgs']; + + // All options start with `--` + let argName = ''; + + for (const inputToken of inputTokens) { + if (inputToken.startsWith('--')) { + argName = inputToken.substr(2); + + if (!args[argName]) { + args[argName] = { + value: undefined, + values: undefined, + }; + } + + // eslint-disable-next-line no-continue + continue; + } else if (!argName) { + (unknownArgs = unknownArgs || []).push(inputToken); + + // eslint-disable-next-line no-continue + continue; + } + + if (Array.isArray(args[argName].values)) { + // @ts-ignore + args[argName].values.push(inputToken); + } else { + // Do we have multiple values for this argumentName, then create array for values + if (args[argName].value !== undefined) { + args[argName].values = [args[argName].value ?? '', inputToken]; + args[argName].value = undefined; + } else { + args[argName].value = inputToken; + } + } + } + + return Object.assign(Object.create(PARSED_COMMAND_INPUT_PROTOTYPE), { + input, + name, + args, + unknownArgs, + }); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts new file mode 100644 index 0000000000000..dbd5347ea99c2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts @@ -0,0 +1,27 @@ +/* + * 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 { ReactNode } from 'react'; +import { CommandDefinition, CommandServiceInterface } from '../types'; +import { ParsedCommandInput } from './parsed_command_input'; +import { HistoryItemComponent } from '../components/history_item'; + +export interface BuiltinCommandServiceInterface extends CommandServiceInterface { + executeBuiltinCommand( + parsedInput: ParsedCommandInput, + contextConsoleService: CommandServiceInterface + ): { result: ReturnType | null; clearBuffer?: boolean }; + + getHelpContent( + parsedInput: ParsedCommandInput, + commandService: CommandServiceInterface + ): Promise<{ result: ReactNode }>; + + isBuiltin(name: string): boolean; + + getCommandUsage(command: CommandDefinition): Promise<{ result: ReactNode }>; +} diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts b/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts new file mode 100644 index 0000000000000..edc7d404fd8dd --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CommandDefinition } from '../types'; + +export const usageFromCommandDefinition = (command: CommandDefinition): string => { + let requiredArgs = ''; + let optionalArgs = ''; + + if (command.args) { + for (const [argName, argDefinition] of Object.entries(command.args)) { + if (argDefinition.required) { + if (requiredArgs.length) { + requiredArgs += ' '; + } + requiredArgs += `--${argName}`; + } else { + if (optionalArgs.length) { + optionalArgs += ' '; + } + optionalArgs += `--${argName}`; + } + } + } + + return `${command.name} ${requiredArgs} ${ + optionalArgs.length > 0 ? `[${optionalArgs}]` : '' + }`.trim(); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/types.ts b/x-pack/plugins/security_solution/public/management/components/console/types.ts new file mode 100644 index 0000000000000..e2b6d5c2a84aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/types.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 { ReactNode } from 'react'; +import { ParsedArgData, ParsedCommandInput } from './service/parsed_command_input'; + +export interface CommandDefinition { + name: string; + about: string; + validator?: () => Promise; + /** If all args are optional, but at least one must be defined, set to true */ + mustHaveArgs?: boolean; + args?: { + [longName: string]: { + required: boolean; + allowMultiples: boolean; + about: string; + /** + * Validate the individual values given to this argument. + * Should return `true` if valid or a string with the error message + */ + validate?: (argData: ParsedArgData) => true | string; + // Selector: Idea is that the schema can plugin in a rich component for the + // user to select something (ex. a file) + // FIXME: implement selector + selector?: () => unknown; + }; + }; +} + +/** + * A command to be executed (as entered by the user) + */ +export interface Command { + /** The raw input entered by the user */ + input: string; + // FIXME:PT this should be a generic that allows for the arguments type to be used + /** An object with the arguments entered by the user and their value */ + args: ParsedCommandInput; + /** The command defined associated with this user command */ + commandDefinition: CommandDefinition; +} + +export interface CommandServiceInterface { + getCommandList(): CommandDefinition[]; + + executeCommand(command: Command): Promise<{ result: ReactNode }>; + + /** + * If defined, then the `help` builtin command will display this output instead of the default one + * which is generated out of the Command list + */ + getHelp?: () => Promise<{ result: ReactNode }>; + + /** + * If defined, then the output of this function will be used to display individual + * command help (`--help`) + */ + getCommandUsage?: (command: CommandDefinition) => Promise<{ result: ReactNode }>; +} diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx new file mode 100644 index 0000000000000..28472e123380a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx @@ -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 React, { memo, useMemo } from 'react'; +import { Console } from '../console'; +import { EndpointConsoleCommandService } from './endpoint_console_command_service'; +import type { HostMetadata } from '../../../../common/endpoint/types'; + +export interface EndpointConsoleProps { + endpoint: HostMetadata; +} + +export const EndpointConsole = memo((props) => { + const consoleService = useMemo(() => { + return new EndpointConsoleCommandService(); + }, []); + + return `} commandService={consoleService} />; +}); + +EndpointConsole.displayName = 'EndpointConsole'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx new file mode 100644 index 0000000000000..5028879bc1a49 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx @@ -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 React, { ReactNode } from 'react'; +import { CommandServiceInterface, CommandDefinition, Command } from '../console'; + +/** + * Endpoint specific Response Actions (commands) for use with Console. + */ +export class EndpointConsoleCommandService implements CommandServiceInterface { + getCommandList(): CommandDefinition[] { + return []; + } + + async executeCommand(command: Command): Promise<{ result: ReactNode }> { + return { result: <> }; + } +} diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts new file mode 100644 index 0000000000000..97f7fb61ae607 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { EndpointConsole } from './endpoint_console'; diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts b/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts index f7e4344cee23c..c7537243abc68 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { ConditionEntryField } from '@kbn/securitysolution-utils'; +import { BlocklistConditionEntryField } from '@kbn/securitysolution-utils'; export const DETAILS_HEADER = i18n.translate('xpack.securitySolution.blocklists.details.header', { defaultMessage: 'Details', @@ -15,7 +15,8 @@ export const DETAILS_HEADER = i18n.translate('xpack.securitySolution.blocklists. export const DETAILS_HEADER_DESCRIPTION = i18n.translate( 'xpack.securitySolution.blocklists.details.header.description', { - defaultMessage: 'Add a blocklist to prevent selected applications from running on your hosts.', + defaultMessage: + 'The blocklist prevents selected applications from running on your hosts by extending the list of processes the Endpoint considers malicious.', } ); @@ -61,29 +62,34 @@ export const VALUE_LABEL = i18n.translate('xpack.securitySolution.blocklists.val defaultMessage: 'Value', }); -export const CONDITION_FIELD_TITLE: { [K in ConditionEntryField]: string } = { - [ConditionEntryField.HASH]: i18n.translate('xpack.securitySolution.blocklists.entry.field.hash', { +export const VALUE_LABEL_HELPER = i18n.translate( + 'xpack.securitySolution.blocklists.value.label.helper', + { + defaultMessage: 'Type or copy & paste one or multiple comma delimited values', + } +); + +export const CONDITION_FIELD_TITLE: { [K in BlocklistConditionEntryField]: string } = { + 'file.hash.*': i18n.translate('xpack.securitySolution.blocklists.entry.field.hash', { defaultMessage: 'Hash', }), - [ConditionEntryField.PATH]: i18n.translate('xpack.securitySolution.blocklists.entry.field.path', { + 'file.path': i18n.translate('xpack.securitySolution.blocklists.entry.field.path', { defaultMessage: 'Path', }), - [ConditionEntryField.SIGNER]: i18n.translate( + 'file.Ext.code_signature': i18n.translate( 'xpack.securitySolution.blocklists.entry.field.signature', { defaultMessage: 'Signature' } ), }; -export const CONDITION_FIELD_DESCRIPTION: { [K in ConditionEntryField]: string } = { - [ConditionEntryField.HASH]: i18n.translate( - 'xpack.securitySolution.blocklists.entry.field.description.hash', - { defaultMessage: 'md5, sha1, or sha256' } - ), - [ConditionEntryField.PATH]: i18n.translate( - 'xpack.securitySolution.blocklists.entry.field.description.path', - { defaultMessage: 'The full path of the application' } - ), - [ConditionEntryField.SIGNER]: i18n.translate( +export const CONDITION_FIELD_DESCRIPTION: { [K in BlocklistConditionEntryField]: string } = { + 'file.hash.*': i18n.translate('xpack.securitySolution.blocklists.entry.field.description.hash', { + defaultMessage: 'md5, sha1, or sha256', + }), + 'file.path': i18n.translate('xpack.securitySolution.blocklists.entry.field.description.path', { + defaultMessage: 'The full path of the application', + }), + 'file.Ext.code_signature': i18n.translate( 'xpack.securitySolution.blocklists.entry.field.description.signature', { defaultMessage: 'The signer of the application' } ), diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx index 45d76614ddce2..75d4b22fe16a1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx @@ -18,14 +18,16 @@ const BLOCKLIST_PAGE_LABELS: ArtifactListPageProps['labels'] = { defaultMessage: 'Blocklist', }), pageAboutInfo: i18n.translate('xpack.securitySolution.blocklist.pageAboutInfo', { - defaultMessage: 'Add a blocklist to block applications or files from running on the endpoint.', + defaultMessage: + 'The blocklist prevents selected applications from running on your hosts by extending the list of processes the Endpoint considers malicious.', }), pageAddButtonTitle: i18n.translate('xpack.securitySolution.blocklist.pageAddButtonTitle', { defaultMessage: 'Add blocklist entry', }), getShowingCountLabel: (total) => i18n.translate('xpack.securitySolution.blocklist.showingTotal', { - defaultMessage: 'Showing {total} {total, plural, one {blocklist} other {blocklists}}', + defaultMessage: + 'Showing {total} {total, plural, one {blocklist entry} other {blocklist entries}}', values: { total }, }), cardActionEditLabel: i18n.translate('xpack.securitySolution.blocklist.cardActionEditLabel', { diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx index 379b8f932ba9d..030538598c8ad 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx @@ -21,10 +21,12 @@ import { EuiTitle, EuiFlexGroup, EuiFlexItem, + EuiToolTip, + EuiIcon, } from '@elastic/eui'; import { OperatingSystem, - ConditionEntryField, + BlocklistConditionEntryField, isPathValid, hasSimpleExecutableName, } from '@kbn/securitysolution-utils'; @@ -49,6 +51,7 @@ import { SELECT_OS_LABEL, VALUE_LABEL, ERRORS, + VALUE_LABEL_HELPER, } from '../../translations'; import { EffectedPolicySelect, @@ -64,7 +67,7 @@ import { isArtifactGlobal } from '../../../../../../common/endpoint/service/arti import type { PolicyData } from '../../../../../../common/endpoint/types'; interface BlocklistEntry { - field: ConditionEntryField; + field: BlocklistConditionEntryField; operator: 'included'; type: 'match_any'; value: string[]; @@ -79,7 +82,7 @@ function createValidationMessage(message: string): React.ReactNode { return
{message}
; } -function getDropdownDisplay(field: ConditionEntryField): React.ReactNode { +function getDropdownDisplay(field: BlocklistConditionEntryField): React.ReactNode { return ( <> {CONDITION_FIELD_TITLE[field]} @@ -115,7 +118,7 @@ export const BlockListForm = memo( const blocklistEntry = useMemo((): BlocklistEntry => { if (!item.entries.length) { return { - field: ConditionEntryField.HASH, + field: 'file.hash.*', operator: 'included', type: 'match_any', value: [], @@ -145,30 +148,41 @@ export const BlockListForm = memo( [] ); - const fieldOptions: Array> = useMemo(() => { - const selectableFields: Array> = [ - ConditionEntryField.HASH, - ConditionEntryField.PATH, - ].map((field) => ({ + const fieldOptions: Array> = useMemo(() => { + const selectableFields: Array> = ( + ['file.hash.*', 'file.path'] as BlocklistConditionEntryField[] + ).map((field) => ({ value: field, inputDisplay: CONDITION_FIELD_TITLE[field], dropdownDisplay: getDropdownDisplay(field), })); if (selectedOs === OperatingSystem.WINDOWS) { selectableFields.push({ - value: ConditionEntryField.SIGNER, - inputDisplay: CONDITION_FIELD_TITLE[ConditionEntryField.SIGNER], - dropdownDisplay: getDropdownDisplay(ConditionEntryField.SIGNER), + value: 'file.Ext.code_signature', + inputDisplay: CONDITION_FIELD_TITLE['file.Ext.code_signature'], + dropdownDisplay: getDropdownDisplay('file.Ext.code_signature'), }); } return selectableFields; }, [selectedOs]); + const valueLabel = useMemo(() => { + return ( +
+ + <> + {VALUE_LABEL} + + +
+ ); + }, []); + const validateValues = useCallback((nextItem: ArtifactFormComponentProps['item']) => { const os = ((nextItem.os_types ?? [])[0] as OperatingSystem) ?? OperatingSystem.WINDOWS; const { - field = ConditionEntryField.HASH, + field = 'file.hash.*', type = 'match_any', value: values = [], } = (nextItem.entries[0] ?? {}) as BlocklistEntry; @@ -188,20 +202,20 @@ export const BlockListForm = memo( } // error if invalid hash - if (field === ConditionEntryField.HASH && values.some((value) => !isValidHash(value))) { + if (field === 'file.hash.*' && values.some((value) => !isValidHash(value))) { newValueErrors.push(createValidationMessage(ERRORS.INVALID_HASH)); } const isInvalidPath = values.some((value) => !isPathValid({ os, field, type, value })); // warn if invalid path - if (field !== ConditionEntryField.HASH && isInvalidPath) { + if (field !== 'file.hash.*' && isInvalidPath) { newValueWarnings.push(createValidationMessage(ERRORS.INVALID_PATH)); } // warn if wildcard if ( - field !== ConditionEntryField.HASH && + field !== 'file.hash.*' && !isInvalidPath && values.some((value) => !hasSimpleExecutableName({ os, type, value })) ) { @@ -260,9 +274,8 @@ export const BlockListForm = memo( { ...blocklistEntry, field: - os !== OperatingSystem.WINDOWS && - blocklistEntry.field === ConditionEntryField.SIGNER - ? ConditionEntryField.HASH + os !== OperatingSystem.WINDOWS && blocklistEntry.field === 'file.Ext.code_signature' + ? 'file.hash.*' : blocklistEntry.field, }, ], @@ -278,7 +291,7 @@ export const BlockListForm = memo( ); const handleOnFieldChange = useCallback( - (field: ConditionEntryField) => { + (field: BlocklistConditionEntryField) => { const nextItem = { ...item, entries: [{ ...blocklistEntry, field }], @@ -432,7 +445,7 @@ export const BlockListForm = memo( new Promise((r) => setTimeout(r, ms)); + +class DevCommandService implements CommandServiceInterface { + getCommandList(): CommandDefinition[] { + return [ + { + name: 'cmd1', + about: 'Runs cmd1', + }, + { + name: 'cmd2', + about: 'runs cmd 2', + args: { + file: { + required: true, + allowMultiples: false, + about: 'Includes file in the run', + validate: () => { + return true; + }, + }, + bad: { + required: false, + allowMultiples: false, + about: 'will fail validation', + validate: () => 'This is a bad value', + }, + }, + }, + { + name: 'cmd-long-delay', + about: 'runs cmd 2', + }, + ]; + } + + async executeCommand(command: Command): Promise<{ result: React.ReactNode }> { + await delay(); + + if (command.commandDefinition.name === 'cmd-long-delay') { + await delay(20000); + } + + return { + result: ( +
+
{`${command.commandDefinition.name}`}
+
{`command input: ${command.input}`}
+ {JSON.stringify(command.args, null, 2)} +
+ ), + }; + } +} + +// ------------------------------------------------------------ +// FOR DEV PURPOSES ONLY +// FIXME:PT Delete once we have support via row actions menu +// ------------------------------------------------------------ +export const DevConsole = memo(() => { + const isConsoleEnabled = useIsExperimentalFeatureEnabled('responseActionsConsoleEnabled'); + + const consoleService = useMemo(() => { + return new DevCommandService(); + }, []); + + const { + urlParams: { showConsole = false }, + } = useUrlParams(); + + return isConsoleEnabled && showConsole ? ( +
+ +
+ ) : null; +}); +DevConsole.displayName = 'DevConsole'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index da6f3b54323c5..3946edb9a0981 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -68,6 +68,7 @@ import { BackToExternalAppButton, BackToExternalAppButtonProps, } from '../../../components/back_to_external_app_button/back_to_external_app_button'; +import { DevConsole } from './dev_console'; const MAX_PAGINATED_ITEM = 9999; const TRANSFORM_URL = '/data/transform'; @@ -664,6 +665,9 @@ export const EndpointList = () => { } headerBackComponent={routeState.backLink && backToPolicyList} > + {/* FIXME: Remove once Console is implemented via ConsoleManagementProvider */} + + {hasSelectedEndpoint && } <> {areEndpointsEnrolling && !hasErrorFindingTotals && ( diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable.test.tsx.snap index 8835a3ac390f3..966512170c156 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable.test.tsx.snap @@ -3,6 +3,7 @@ exports[`Embeddable it renders 1`] = `
(({ children }) => ( -
+
{children} diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx index 4b8a5b6dd9940..2166d6b495e75 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx @@ -109,7 +109,7 @@ describe('EmbeddedMapComponent', () => { beforeEach(() => { setQuery.mockClear(); - mockGetStorage.mockReturnValue(false); + mockGetStorage.mockReturnValue(true); }); afterEach(() => { @@ -190,36 +190,40 @@ describe('EmbeddedMapComponent', () => { }); test('map hidden on close', async () => { + mockGetStorage.mockReturnValue(false); const wrapper = mount( ); + expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(false); + const container = wrapper.find('[data-test-subj="false-toggle-network-map"]').at(0); container.simulate('click'); await waitFor(() => { wrapper.update(); expect(mockSetStorage).toHaveBeenNthCalledWith(1, 'network_map_visbile', true); + expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(true); }); }); test('map visible on open', async () => { - mockGetStorage.mockReturnValue(true); - const wrapper = mount( ); + expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(true); const container = wrapper.find('[data-test-subj="true-toggle-network-map"]').at(0); container.simulate('click'); await waitFor(() => { wrapper.update(); expect(mockSetStorage).toHaveBeenNthCalledWith(1, 'network_map_visbile', false); + expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(false); }); }); }); diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx index 803688bf21343..083f858dc7742 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx @@ -245,6 +245,29 @@ export const EmbeddedMapComponent = ({ [storage] ); + const content = useMemo(() => { + if (!storageValue) { + return null; + } + return ( + + + + + + + {isIndexError ? ( + + ) : embeddable != null ? ( + + ) : ( + + )} + + + ); + }, [embeddable, isIndexError, portalNode, services, storageValue]); + return isError ? null : ( - - - - - - - {isIndexError ? ( - - ) : embeddable != null ? ( - - ) : ( - - )} - - + {content} ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.test.tsx new file mode 100644 index 0000000000000..d5dee1b84f8d7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.test.tsx @@ -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 { useNetworkKpiDns } from '../../../containers/kpi_network/dns'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiDns } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/dns'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('DNS KPI', () => { + const mockUseNetworkKpiDns = useNetworkKpiDns as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiDns.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiDns.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiDns.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx index 6291e7fd4dc12..94e81c2d80d4a 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiDnsQueriesLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_dns_queries'; +import { useNetworkKpiDns, ID } from '../../../containers/kpi_network/dns'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; -import { useNetworkKpiDns } from '../../../containers/kpi_network/dns'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -38,12 +39,17 @@ const NetworkKpiDnsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiDns({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -58,6 +64,7 @@ const NetworkKpiDnsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts b/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts index 6f35c4dead250..f5ed1ebde6992 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts @@ -227,7 +227,9 @@ export const mockEnableChartsData = { ], from: '2019-06-15T06:00:00.000Z', id: 'statItem', + loading: false, statKey: 'UniqueIps', + setQuerySkip: jest.fn(), to: '2019-06-18T06:00:00.000Z', narrowDateRange: mockNarrowDateRange, areaChartLensAttributes: kpiUniquePrivateIpsAreaLensAttributes, diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.test.tsx new file mode 100644 index 0000000000000..87f1a173740f3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.test.tsx @@ -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 { useNetworkKpiNetworkEvents } from '../../../containers/kpi_network/network_events'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiNetworkEvents } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/network_events'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Network Events KPI', () => { + const mockUseNetworkKpiNetworkEvents = useNetworkKpiNetworkEvents as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiNetworkEvents.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiNetworkEvents.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiNetworkEvents.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx index ad2487b65f1de..52aa98a117afa 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx @@ -5,16 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { euiPaletteColorBlind } from '@elastic/eui'; import { StatItems } from '../../../../common/components/stat_items'; -import { useNetworkKpiNetworkEvents } from '../../../containers/kpi_network/network_events'; - +import { ID, useNetworkKpiNetworkEvents } from '../../../containers/kpi_network/network_events'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; import { kpiNetworkEventsLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_network_events'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; const euiVisColorPalette = euiPaletteColorBlind(); const euiColorVis1 = euiVisColorPalette[1]; @@ -43,12 +43,17 @@ const NetworkKpiNetworkEventsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiNetworkEvents({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -63,6 +68,7 @@ const NetworkKpiNetworkEventsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.test.tsx new file mode 100644 index 0000000000000..28bf73eb6b2d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.test.tsx @@ -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 { useNetworkKpiTlsHandshakes } from '../../../containers/kpi_network/tls_handshakes'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiTlsHandshakes } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/tls_handshakes'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('TLS Handshakes KPI', () => { + const mockUseNetworkKpiTlsHandshakes = useNetworkKpiTlsHandshakes as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiTlsHandshakes.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiTlsHandshakes.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiTlsHandshakes.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx index 0bdbd0a23d9f1..c25a4cd140108 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx @@ -5,14 +5,15 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiTlsHandshakesLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_tls_handshakes'; +import { useNetworkKpiTlsHandshakes, ID } from '../../../containers/kpi_network/tls_handshakes'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; -import { useNetworkKpiTlsHandshakes } from '../../../containers/kpi_network/tls_handshakes'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -37,12 +38,17 @@ const NetworkKpiTlsHandshakesComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiTlsHandshakes({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -57,6 +63,7 @@ const NetworkKpiTlsHandshakesComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.test.tsx new file mode 100644 index 0000000000000..c1a28bdc28692 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.test.tsx @@ -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 { useNetworkKpiUniqueFlows } from '../../../containers/kpi_network/unique_flows'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiUniqueFlows } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/unique_flows'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Unique Flows KPI', () => { + const mockUseNetworkKpiUniqueFlows = useNetworkKpiUniqueFlows as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiUniqueFlows.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiUniqueFlows.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiUniqueFlows.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx index 5c3624130b36f..d6874818ab901 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx @@ -5,14 +5,15 @@ * 2.0. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiUniqueFlowIdsLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_flow_ids'; +import { useNetworkKpiUniqueFlows, ID } from '../../../containers/kpi_network/unique_flows'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; -import { useNetworkKpiUniqueFlows } from '../../../containers/kpi_network/unique_flows'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -37,12 +38,17 @@ const NetworkKpiUniqueFlowsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiUniqueFlows({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -57,6 +63,7 @@ const NetworkKpiUniqueFlowsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.test.tsx new file mode 100644 index 0000000000000..25807f3dc2cad --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.test.tsx @@ -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 { useNetworkKpiUniquePrivateIps } from '../../../containers/kpi_network/unique_private_ips'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiUniquePrivateIps } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/unique_private_ips'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Unique Private IPs KPI', () => { + const mockUseNetworkKpiUniquePrivateIps = useNetworkKpiUniquePrivateIps as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiUniquePrivateIps.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiUniquePrivateIps.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiUniquePrivateIps.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx index e546deb7019e8..91791d09f8113 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx @@ -5,11 +5,14 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { euiPaletteColorBlind } from '@elastic/eui'; import { StatItems } from '../../../../common/components/stat_items'; -import { useNetworkKpiUniquePrivateIps } from '../../../containers/kpi_network/unique_private_ips'; +import { + useNetworkKpiUniquePrivateIps, + ID, +} from '../../../containers/kpi_network/unique_private_ips'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; import { kpiUniquePrivateIpsSourceMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_source_metric'; @@ -17,6 +20,7 @@ import { kpiUniquePrivateIpsDestinationMetricLensAttributes } from '../../../../ import { kpiUniquePrivateIpsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_area'; import { kpiUniquePrivateIpsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_bar'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; const euiVisColorPalette = euiPaletteColorBlind(); const euiColorVis2 = euiVisColorPalette[2]; @@ -62,12 +66,17 @@ const NetworkKpiUniquePrivateIpsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiUniquePrivateIps({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -82,6 +91,7 @@ const NetworkKpiUniquePrivateIpsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/network_dns_table/__snapshots__/index.test.tsx.snap index 0119859d37672..c43df33721bf1 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/__snapshots__/index.test.tsx.snap @@ -141,6 +141,7 @@ exports[`NetworkTopNFlow Table Component rendering it renders the default Networ isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={80} type="page" diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx index fc28067866146..2757baef2c1f4 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx @@ -34,6 +34,19 @@ describe('NetworkTopNFlow Table Component', () => { let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); const mount = useMountAppended(); + const defaultProps = { + data: mockData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo), + id: 'dns', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo), + totalCount: mockData.totalCount, + type: networkModel.NetworkType.page, + }; + beforeEach(() => { store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); @@ -42,17 +55,7 @@ describe('NetworkTopNFlow Table Component', () => { test('it renders the default NetworkTopNFlow table', () => { const wrapper = shallow( - + ); @@ -64,17 +67,7 @@ describe('NetworkTopNFlow Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx index 016a40f7e2a17..a87908d27e63d 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx @@ -32,6 +32,7 @@ interface NetworkDnsTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -56,6 +57,7 @@ const NetworkDnsTableComponent: React.FC = ({ loading, loadPage, showMorePagesIndicator, + setQuerySkip, totalCount, type, }) => { @@ -153,6 +155,7 @@ const NetworkDnsTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={sorting} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/network_http_table/__snapshots__/index.test.tsx.snap index c5df0f6603fbf..c26c85d311959 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/__snapshots__/index.test.tsx.snap @@ -95,6 +95,7 @@ exports[`NetworkHttp Table Component rendering it renders the default NetworkHtt isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={false} totalCount={4} type="page" diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx index 2a85b31791f5a..e8bac5e54765c 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx @@ -31,6 +31,18 @@ jest.mock('../../../common/components/link_to'); describe('NetworkHttp Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; + const defaultProps = { + data: mockData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo), + id: 'http', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo), + totalCount: mockData.totalCount, + type: networkModel.NetworkType.page, + }; const { storage } = createSecuritySolutionStorageMock(); let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); @@ -44,17 +56,7 @@ describe('NetworkHttp Table Component', () => { test('it renders the default NetworkHttp table', () => { const wrapper = shallow( - + ); @@ -66,17 +68,7 @@ describe('NetworkHttp Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx index 2f0c4a105606c..5bdfd45951292 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx @@ -23,6 +23,7 @@ interface NetworkHttpTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -46,6 +47,7 @@ const NetworkHttpTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -123,6 +125,7 @@ const NetworkHttpTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={sorting} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/__snapshots__/index.test.tsx.snap index ecf7d2d0cd16f..cd13be9cef38b 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/__snapshots__/index.test.tsx.snap @@ -151,6 +151,7 @@ exports[`NetworkTopCountries Table Component rendering it renders the IP Details isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={524} type="details" @@ -308,6 +309,7 @@ exports[`NetworkTopCountries Table Component rendering it renders the default Ne isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={524} type="page" diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx index a0727fad65f18..12dc41961bdf5 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx @@ -33,6 +33,24 @@ describe('NetworkTopCountries Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; const mount = useMountAppended(); + const defaultProps = { + data: mockData.NetworkTopCountries.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.NetworkTopCountries.pageInfo), + flowTargeted: FlowTargetSourceDest.source, + id: 'topCountriesSource', + indexPattern: mockIndexPattern, + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr( + false, + 'showMorePagesIndicator', + mockData.NetworkTopCountries.pageInfo + ), + totalCount: mockData.NetworkTopCountries.totalCount, + type: networkModel.NetworkType.page, + }; const { storage } = createSecuritySolutionStorageMock(); let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); @@ -45,23 +63,7 @@ describe('NetworkTopCountries Table Component', () => { test('it renders the default NetworkTopCountries table', () => { const wrapper = shallow( - + ); @@ -70,23 +72,7 @@ describe('NetworkTopCountries Table Component', () => { test('it renders the IP Details NetworkTopCountries table', () => { const wrapper = shallow( - + ); @@ -98,23 +84,7 @@ describe('NetworkTopCountries Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); expect(store.getState().network.page.queries.topCountriesSource.sort).toEqual({ diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx index 80de694f89484..00c9c7d0aaf30 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx @@ -35,6 +35,7 @@ interface NetworkTopCountriesTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -62,6 +63,7 @@ const NetworkTopCountriesTableComponent: React.FC isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -170,6 +172,7 @@ const NetworkTopCountriesTableComponent: React.FC loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={{ field, direction: sort.direction }} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/__snapshots__/index.test.tsx.snap index 07874f9f39f0b..7909eba5b0d88 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/__snapshots__/index.test.tsx.snap @@ -99,6 +99,7 @@ exports[`NetworkTopNFlow Table Component rendering it renders the default Networ isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={524} type="details" @@ -204,6 +205,7 @@ exports[`NetworkTopNFlow Table Component rendering it renders the default Networ isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={524} type="page" diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx index e2b9447b58806..b5df028f4d7a4 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx @@ -35,6 +35,19 @@ describe('NetworkTopNFlow Table Component', () => { const { storage } = createSecuritySolutionStorageMock(); let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); const mount = useMountAppended(); + const defaultProps = { + data: mockData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo), + flowTargeted: FlowTargetSourceDest.source, + id: 'topNFlowSource', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo), + totalCount: mockData.totalCount, + type: networkModel.NetworkType.page, + }; beforeEach(() => { store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); @@ -44,18 +57,7 @@ describe('NetworkTopNFlow Table Component', () => { test('it renders the default NetworkTopNFlow table on the Network page', () => { const wrapper = shallow( - + ); @@ -65,18 +67,7 @@ describe('NetworkTopNFlow Table Component', () => { test('it renders the default NetworkTopNFlow table on the IP Details page', () => { const wrapper = shallow( - + ); @@ -88,18 +79,7 @@ describe('NetworkTopNFlow Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); expect(store.getState().network.page.queries.topNFlowSource.sort).toEqual({ diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx index a612d3e4e1093..12895226a82eb 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx @@ -31,6 +31,7 @@ interface NetworkTopNFlowTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -57,6 +58,7 @@ const NetworkTopNFlowTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -166,6 +168,7 @@ const NetworkTopNFlowTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={sorting} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx index 3a1a5efef6b89..a54b219985817 100644 --- a/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx @@ -29,7 +29,18 @@ jest.mock('../../../common/lib/kibana'); describe('Tls Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; - + const defaultProps = { + data: mockTlsData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockTlsData.pageInfo), + id: 'tls', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockTlsData.pageInfo), + totalCount: 1, + type: networkModel.NetworkType.details, + }; const { storage } = createSecuritySolutionStorageMock(); let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); const mount = useMountAppended(); @@ -42,17 +53,7 @@ describe('Tls Table Component', () => { test('it renders the default Domains table', () => { const wrapper = shallow( - + ); @@ -64,17 +65,7 @@ describe('Tls Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); expect(store.getState().network.details.queries?.tls.sort).toEqual({ diff --git a/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx index 34a218db39fac..60079e50f27ce 100644 --- a/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx @@ -33,6 +33,7 @@ interface TlsTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -58,6 +59,7 @@ const TlsTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -135,6 +137,7 @@ const TlsTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} sorting={getSortField(sort)} totalCount={fakeTotalCount} updateActivePage={updateActivePage} diff --git a/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx index 3861433b4dcb0..95e014332d42a 100644 --- a/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx @@ -40,22 +40,25 @@ describe('Users Table Component', () => { store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); + const defaultProps = { + data: mockUsersData.edges, + flowTarget: FlowTarget.source, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockUsersData.pageInfo), + id: 'user', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockUsersData.pageInfo), + totalCount: 1, + type: networkModel.NetworkType.details, + }; + describe('Rendering', () => { test('it renders the default Users table', () => { const wrapper = shallow( - + ); @@ -67,18 +70,7 @@ describe('Users Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); expect(store.getState().network.details.queries?.users.sort).toEqual({ diff --git a/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx index 66c36208fd98a..efbe5b7d1d010 100644 --- a/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx @@ -38,6 +38,7 @@ interface UsersTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -64,6 +65,7 @@ const UsersTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -141,6 +143,7 @@ const UsersTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} sorting={getSortField(sort)} totalCount={fakeTotalCount} updateActivePage={updateActivePage} diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.test.tsx new file mode 100644 index 0000000000000..44b8472a0606c --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiDns } from './index'; + +describe('kpi network - dns', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiDns(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx index 63fb751572b0b..89f58f547bd75 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx @@ -30,7 +30,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiDnsQuery'; +export const ID = 'networkKpiDnsQuery'; export interface NetworkKpiDnsArgs { dnsQueries: number; @@ -160,5 +160,13 @@ export const useNetworkKpiDns = ({ }; }, [networkKpiDnsRequest, networkKpiDnsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiDnsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.test.tsx new file mode 100644 index 0000000000000..4171a86fae9cc --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiNetworkEvents } from './index'; + +describe('kpi network - network events', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiNetworkEvents(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx index 4ecf455a31724..51a5367446b6e 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx @@ -30,7 +30,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiNetworkEventsQuery'; +export const ID = 'networkKpiNetworkEventsQuery'; export interface NetworkKpiNetworkEventsArgs { networkEvents: number; @@ -163,5 +163,13 @@ export const useNetworkKpiNetworkEvents = ({ }; }, [networkKpiNetworkEventsRequest, networkKpiNetworkEventsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiNetworkEventsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.test.tsx new file mode 100644 index 0000000000000..bad0e6ad71512 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiTlsHandshakes } from './index'; + +describe('kpi network - tls handshakes', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiTlsHandshakes(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx index 2dbf909334b15..ba42d79ad0eed 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx @@ -30,7 +30,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiTlsHandshakesQuery'; +export const ID = 'networkKpiTlsHandshakesQuery'; export interface NetworkKpiTlsHandshakesArgs { tlsHandshakes: number; @@ -163,5 +163,13 @@ export const useNetworkKpiTlsHandshakes = ({ }; }, [networkKpiTlsHandshakesRequest, networkKpiTlsHandshakesSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiTlsHandshakesResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.test.tsx new file mode 100644 index 0000000000000..83cb2a40aabce --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiUniqueFlows } from './index'; + +describe('kpi network - unique flows', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiUniqueFlows(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx index 612aac175fd9a..130efc8d755a6 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx @@ -29,7 +29,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiUniqueFlowsQuery'; +export const ID = 'networkKpiUniqueFlowsQuery'; export interface NetworkKpiUniqueFlowsArgs { uniqueFlowId: number; @@ -84,7 +84,6 @@ export const useNetworkKpiUniqueFlows = ({ const asyncSearch = async () => { abortCtrl.current = new AbortController(); setLoading(true); - searchSubscription$.current = data.search .search( request, @@ -155,5 +154,13 @@ export const useNetworkKpiUniqueFlows = ({ }; }, [networkKpiUniqueFlowsRequest, networkKpiUniqueFlowsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiUniqueFlowsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.test.tsx new file mode 100644 index 0000000000000..370c4e671e886 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiUniquePrivateIps } from './index'; + +describe('kpi network - unique private ips', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiUniquePrivateIps(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx index 42a8e30a8f906..b68c4fcb698c0 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx @@ -31,7 +31,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiUniquePrivateIpsQuery'; +export const ID = 'networkKpiUniquePrivateIpsQuery'; export interface NetworkKpiUniquePrivateIpsArgs { uniqueDestinationPrivateIps: number; @@ -175,5 +175,13 @@ export const useNetworkKpiUniquePrivateIps = ({ }; }, [networkKpiUniquePrivateIpsRequest, networkKpiUniquePrivateIpsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiUniquePrivateIpsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.test.tsx new file mode 100644 index 0000000000000..f303cdf85a5f8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.test.tsx @@ -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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkDns } from './index'; +import { NetworkType } from '../../store/model'; + +describe('useNetworkDns', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkDns(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx index 47e60f27a7dbd..86949777dd535 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx @@ -32,7 +32,7 @@ import { getInspectResponse } from '../../../helpers'; import { InspectResponse } from '../../../types'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkDnsQuery'; +export const ID = 'networkDnsQuery'; export interface NetworkDnsArgs { id: string; @@ -207,5 +207,13 @@ export const useNetworkDns = ({ }; }, [networkDnsRequest, networkDnsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkDnsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_http/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/network_http/index.test.tsx new file mode 100644 index 0000000000000..b687896efcea4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_http/index.test.tsx @@ -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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkHttp } from './index'; +import { NetworkType } from '../../store/model'; + +describe('useNetworkHttp', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkHttp(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx index 98105f5cac25a..eba2b22f30e29 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx @@ -31,7 +31,7 @@ import { InspectResponse } from '../../../types'; import { getInspectResponse } from '../../../helpers'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkHttpQuery'; +export const ID = 'networkHttpQuery'; export interface NetworkHttpArgs { id: string; @@ -94,7 +94,7 @@ export const useNetworkHttp = ({ const [networkHttpResponse, setNetworkHttpResponse] = useState({ networkHttp: [], - id: ID, + id, inspect: { dsl: [], response: [], @@ -116,11 +116,9 @@ export const useNetworkHttp = ({ if (request == null || skip) { return; } - const asyncSearch = async () => { abortCtrl.current = new AbortController(); setLoading(true); - searchSubscription$.current = data.search .search(request, { strategy: 'securitySolutionSearchStrategy', @@ -193,5 +191,13 @@ export const useNetworkHttp = ({ }; }, [networkHttpRequest, networkHttpSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkHttpResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.test.tsx new file mode 100644 index 0000000000000..fe7507c85567a --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkTopCountries } from './index'; +import { NetworkType } from '../../store/model'; +import { FlowTargetSourceDest } from '../../../../common/search_strategy'; + +describe('useNetworkTopCountries', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + flowTarget: FlowTargetSourceDest.source, + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkTopCountries(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx index f64ee85ab7cf0..6110e84804fe3 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx @@ -32,7 +32,7 @@ import * as i18n from './translations'; import { useTransforms } from '../../../transforms/containers/use_transforms'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkTopCountriesQuery'; +export const ID = 'networkTopCountriesQuery'; export interface NetworkTopCountriesArgs { id: string; @@ -218,5 +218,13 @@ export const useNetworkTopCountries = ({ }; }, [networkTopCountriesRequest, networkTopCountriesSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkTopCountriesResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.test.tsx new file mode 100644 index 0000000000000..c31dec3ce0aed --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkTopNFlow } from './index'; +import { NetworkType } from '../../store/model'; +import { FlowTargetSourceDest } from '../../../../common/search_strategy'; + +describe('useNetworkTopNFlow', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + flowTarget: FlowTargetSourceDest.source, + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkTopNFlow(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx index 0b4c164782f3d..022b76c315c17 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx @@ -32,7 +32,7 @@ import * as i18n from './translations'; import { useTransforms } from '../../../transforms/containers/use_transforms'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkTopNFlowQuery'; +export const ID = 'networkTopNFlowQuery'; export interface NetworkTopNFlowArgs { id: string; @@ -215,5 +215,13 @@ export const useNetworkTopNFlow = ({ }; }, [networkTopNFlowRequest, networkTopNFlowSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkTopNFlowResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/tls/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/tls/index.test.tsx new file mode 100644 index 0000000000000..6b236d4ddfb20 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/tls/index.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkTls } from './index'; +import { NetworkType } from '../../store/model'; +import { FlowTargetSourceDest } from '../../../../common/search_strategy'; + +describe('useNetworkTls', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + flowTarget: FlowTargetSourceDest.source, + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + ip: '1.1.1.1', + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkTls(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx b/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx index 754f0cac8868c..ed771455446c0 100644 --- a/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx @@ -29,7 +29,7 @@ import { getInspectResponse } from '../../../helpers'; import { FlowTargetSourceDest, PageInfoPaginated } from '../../../../common/search_strategy'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkTlsQuery'; +export const ID = 'networkTlsQuery'; export interface NetworkTlsArgs { id: string; @@ -196,5 +196,13 @@ export const useNetworkTls = ({ }; }, [networkTlsRequest, networkTlsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkTlsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/users/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/users/index.test.tsx new file mode 100644 index 0000000000000..4a6c1fac4191c --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/users/index.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkUsers } from './index'; +import { NetworkType } from '../../store/model'; +import { FlowTarget } from '../../../../common/search_strategy'; + +describe('useNetworkUsers', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + ip: '1.1.1.1', + flowTarget: FlowTarget.source, + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkUsers(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/users/index.tsx b/x-pack/plugins/security_solution/public/network/containers/users/index.tsx index d4be09f97591d..9ad2c59f6bb79 100644 --- a/x-pack/plugins/security_solution/public/network/containers/users/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/users/index.tsx @@ -31,7 +31,7 @@ import { InspectResponse } from '../../../types'; import { PageInfoPaginated } from '../../../../common/search_strategy'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkUsersQuery'; +export const ID = 'networkUsersQuery'; export interface NetworkUsersArgs { id: string; @@ -195,5 +195,13 @@ export const useNetworkUsers = ({ }; }, [networkUsersRequest, networkUsersSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkUsersResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx index 4a4004b9a5f0c..d615bd8264b4b 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; import { OwnProps } from './types'; -import { useNetworkHttp } from '../../containers/network_http'; +import { useNetworkHttp, ID } from '../../containers/network_http'; import { NetworkHttpTable } from '../../components/network_http_table'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkHttpTableManage = manageQuery(NetworkHttpTable); @@ -24,6 +25,11 @@ export const NetworkHttpQueryTable = ({ startDate, type, }: OwnProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkHttp, pageInfo, refetch, totalCount }, @@ -32,7 +38,7 @@ export const NetworkHttpQueryTable = ({ filterQuery, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -48,6 +54,7 @@ export const NetworkHttpQueryTable = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx index 742f0f6ff9a9d..4243635ebb218 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkWithIndexComponentsQueryTableProps } from './types'; -import { useNetworkTopCountries } from '../../containers/network_top_countries'; +import { useNetworkTopCountries, ID } from '../../containers/network_top_countries'; import { NetworkTopCountriesTable } from '../../components/network_top_countries_table'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkTopCountriesTableManage = manageQuery(NetworkTopCountriesTable); @@ -26,6 +27,11 @@ export const NetworkTopCountriesQueryTable = ({ type, indexPattern, }: NetworkWithIndexComponentsQueryTableProps) => { + const { toggleStatus } = useQueryToggle(`${ID}-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkTopCountries, pageInfo, refetch, totalCount }, @@ -35,7 +41,7 @@ export const NetworkTopCountriesQueryTable = ({ filterQuery, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -53,6 +59,7 @@ export const NetworkTopCountriesQueryTable = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/pages/details/network_top_n_flow_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/network_top_n_flow_query_table.tsx index 374dd6e6564e3..3df5397600c12 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/network_top_n_flow_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/network_top_n_flow_query_table.tsx @@ -6,11 +6,12 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkTopNFlowTable } from '../../components/network_top_n_flow_table'; -import { useNetworkTopNFlow } from '../../containers/network_top_n_flow'; +import { useNetworkTopNFlow, ID } from '../../containers/network_top_n_flow'; import { NetworkWithIndexComponentsQueryTableProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkTopNFlowTableManage = manageQuery(NetworkTopNFlowTable); @@ -25,6 +26,11 @@ export const NetworkTopNFlowQueryTable = ({ startDate, type, }: NetworkWithIndexComponentsQueryTableProps) => { + const { toggleStatus } = useQueryToggle(`${ID}-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount }, @@ -34,7 +40,7 @@ export const NetworkTopNFlowQueryTable = ({ flowTarget, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -51,6 +57,7 @@ export const NetworkTopNFlowQueryTable = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx index d3da639c8cf98..f4539e1ffc63d 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx @@ -6,11 +6,12 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { manageQuery } from '../../../common/components/page/manage_query'; import { TlsTable } from '../../components/tls_table'; -import { useNetworkTls } from '../../containers/tls'; +import { ID, useNetworkTls } from '../../containers/tls'; import { TlsQueryTableComponentProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const TlsTableManage = manageQuery(TlsTable); @@ -25,6 +26,11 @@ export const TlsQueryTable = ({ startDate, type, }: TlsQueryTableComponentProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { id, inspect, isInspected, tls, totalCount, pageInfo, loadPage, refetch }] = useNetworkTls({ endDate, @@ -32,7 +38,7 @@ export const TlsQueryTable = ({ flowTarget, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -49,6 +55,7 @@ export const TlsQueryTable = ({ showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} totalCount={totalCount} type={type} /> diff --git a/x-pack/plugins/security_solution/public/network/pages/details/users_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/users_query_table.tsx index a73835985d7c5..9eb27c399ffbf 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/users_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/users_query_table.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; -import { useNetworkUsers } from '../../containers/users'; +import { useNetworkUsers, ID } from '../../containers/users'; import { NetworkComponentsQueryProps } from './types'; import { UsersTable } from '../../components/users_table'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const UsersTableManage = manageQuery(UsersTable); @@ -24,6 +25,11 @@ export const UsersQueryTable = ({ startDate, type, }: NetworkComponentsQueryProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, networkUsers, totalCount, pageInfo, loadPage, refetch }, @@ -32,7 +38,7 @@ export const UsersQueryTable = ({ filterQuery, flowTarget, ip, - skip, + skip: querySkip, startDate, }); @@ -49,6 +55,7 @@ export const UsersQueryTable = ({ showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} totalCount={totalCount} type={type} /> diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/countries_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/countries_query_tab_body.tsx index e4bb00d1cb632..b390ccdcfff82 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/countries_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/countries_query_tab_body.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkTopCountriesTable } from '../../components/network_top_countries_table'; -import { useNetworkTopCountries } from '../../containers/network_top_countries'; +import { useNetworkTopCountries, ID } from '../../containers/network_top_countries'; import { networkModel } from '../../store'; import { manageQuery } from '../../../common/components/page/manage_query'; import { IPsQueryTabBodyProps as CountriesQueryTabBodyProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkTopCountriesTableManage = manageQuery(NetworkTopCountriesTable); @@ -27,6 +28,11 @@ export const CountriesQueryTabBody = ({ indexPattern, flowTarget, }: CountriesQueryTabBodyProps) => { + const { toggleStatus } = useQueryToggle(`${ID}-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkTopCountries, pageInfo, refetch, totalCount }, @@ -35,7 +41,7 @@ export const CountriesQueryTabBody = ({ flowTarget, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type: networkModel.NetworkType.page, }); @@ -53,6 +59,7 @@ export const CountriesQueryTabBody = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={networkModel.NetworkType.page} diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx index 21404690438a0..0ad309522a3e5 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import React, { useEffect, useCallback, useMemo } from 'react'; +import React, { useEffect, useCallback, useMemo, useState } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkDnsTable } from '../../components/network_dns_table'; -import { useNetworkDns } from '../../containers/network_dns'; +import { useNetworkDns, ID } from '../../containers/network_dns'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkComponentQueryProps } from './types'; @@ -24,6 +24,7 @@ import { MatrixHistogramType } from '../../../../common/search_strategy/security import { networkSelectors } from '../../store'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { dnsTopDomainsLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/network/dns_top_domains'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const HISTOGRAM_ID = 'networkDnsHistogramQuery'; @@ -72,6 +73,11 @@ const DnsQueryTabBodyComponent: React.FC = ({ }; }, [deleteQuery]); + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { totalCount, networkDns, pageInfo, loadPage, id, inspect, isInspected, refetch }, @@ -80,7 +86,7 @@ const DnsQueryTabBodyComponent: React.FC = ({ endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type, }); @@ -122,6 +128,7 @@ const DnsQueryTabBodyComponent: React.FC = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx index bf9b0079650b2..98570a2f2f740 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkHttpTable } from '../../components/network_http_table'; -import { useNetworkHttp } from '../../containers/network_http'; +import { ID, useNetworkHttp } from '../../containers/network_http'; import { networkModel } from '../../store'; import { manageQuery } from '../../../common/components/page/manage_query'; import { HttpQueryTabBodyProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkHttpTableManage = manageQuery(NetworkHttpTable); @@ -25,6 +26,11 @@ export const HttpQueryTabBody = ({ startDate, setQuery, }: HttpQueryTabBodyProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkHttp, pageInfo, refetch, totalCount }, @@ -32,7 +38,7 @@ export const HttpQueryTabBody = ({ endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type: networkModel.NetworkType.page, }); @@ -48,6 +54,7 @@ export const HttpQueryTabBody = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={networkModel.NetworkType.page} diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx index aa21fe6066415..a497a35fe3551 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkTopNFlowTable } from '../../components/network_top_n_flow_table'; -import { useNetworkTopNFlow } from '../../containers/network_top_n_flow'; +import { ID, useNetworkTopNFlow } from '../../containers/network_top_n_flow'; import { networkModel } from '../../store'; import { manageQuery } from '../../../common/components/page/manage_query'; import { IPsQueryTabBodyProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkTopNFlowTableManage = manageQuery(NetworkTopNFlowTable); @@ -26,6 +27,11 @@ export const IPsQueryTabBody = ({ setQuery, flowTarget, }: IPsQueryTabBodyProps) => { + const { toggleStatus } = useQueryToggle(`${ID}-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount }, @@ -34,7 +40,7 @@ export const IPsQueryTabBody = ({ flowTarget, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type: networkModel.NetworkType.page, }); @@ -51,6 +57,7 @@ export const IPsQueryTabBody = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={networkModel.NetworkType.page} diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx index 58c6f755b9175..c06a26f5d9192 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; -import { useNetworkTls } from '../../../network/containers/tls'; +import { useNetworkTls, ID } from '../../containers/tls'; import { TlsTable } from '../../components/tls_table'; import { TlsQueryTabBodyProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const TlsTableManage = manageQuery(TlsTable); @@ -25,6 +26,11 @@ const TlsQueryTabBodyComponent: React.FC = ({ startDate, type, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { id, inspect, isInspected, tls, totalCount, pageInfo, loadPage, refetch }] = useNetworkTls({ endDate, @@ -32,7 +38,7 @@ const TlsQueryTabBodyComponent: React.FC = ({ flowTarget, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -49,6 +55,7 @@ const TlsQueryTabBodyComponent: React.FC = ({ showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} totalCount={totalCount} type={type} /> diff --git a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx index 1407bf960843e..23cd7f707dfe8 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx @@ -25,6 +25,8 @@ import { inputsActions } from '../../common/store/inputs'; import { Network } from './network'; import { NetworkRoutes } from './navigation'; import { mockCasesContract } from '../../../../cases/public/mocks'; +import { APP_UI_ID, SecurityPageName } from '../../../common/constants'; +import { getAppLandingUrl } from '../../common/components/link_to/redirect_to_overview'; jest.mock('../../common/containers/sourcerer'); @@ -76,6 +78,7 @@ const mockProps = { }; const mockMapVisibility = jest.fn(); +const mockNavigateToApp = jest.fn(); jest.mock('../../common/lib/kibana', () => { const original = jest.requireActual('../../common/lib/kibana'); @@ -90,6 +93,7 @@ jest.mock('../../common/lib/kibana', () => { siem: { crud_alerts: true, read_alerts: true }, maps: mockMapVisibility(), }, + navigateToApp: mockNavigateToApp, }, storage: { get: () => true, @@ -112,20 +116,27 @@ describe('Network page - rendering', () => { beforeAll(() => { mockMapVisibility.mockReturnValue({ show: true }); }); + beforeEach(() => { + jest.clearAllMocks(); + }); test('it renders the Setup Instructions text when no index is available', () => { mockUseSourcererDataView.mockReturnValue({ selectedPatterns: [], indicesExist: false, }); - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); + + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); }); test('it DOES NOT render the Setup Instructions text when an index is available', async () => { @@ -134,7 +145,7 @@ describe('Network page - rendering', () => { indicesExist: true, indexPattern: {}, }); - const wrapper = mount( + mount( @@ -142,7 +153,7 @@ describe('Network page - rendering', () => { ); await waitFor(() => { - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); + expect(mockNavigateToApp).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx index 55903a8b47665..08639f48864b3 100644 --- a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx @@ -22,10 +22,12 @@ import { MatrixHistogramConfigs, MatrixHistogramOption, } from '../../../common/components/matrix_histogram/types'; -import { eventsStackByOptions } from '../../../hosts/pages/navigation'; import { convertToBuildEsQuery } from '../../../common/lib/keury'; import { useKibana, useUiSetting$ } from '../../../common/lib/kibana'; -import { histogramConfigs } from '../../../hosts/pages/navigation/events_query_tab_body'; +import { + eventsStackByOptions, + histogramConfigs, +} from '../../../common/components/events_tab/events_query_tab_body'; import { getEsQueryConfig } from '../../../../../../../src/plugins/data/common'; import { HostsTableType } from '../../../hosts/store/model'; import { InputsModelId } from '../../../common/store/inputs/constants'; diff --git a/x-pack/plugins/security_solution/public/overview/components/landing_cards/index.tsx b/x-pack/plugins/security_solution/public/overview/components/landing_cards/index.tsx new file mode 100644 index 0000000000000..d8852d8603518 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/landing_cards/index.tsx @@ -0,0 +1,156 @@ +/* + * 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, { memo, useMemo } from 'react'; +import { + EuiButton, + EuiCard, + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiLink, + EuiPageHeader, + EuiToolTip, +} from '@elastic/eui'; +import styled from 'styled-components'; +import * as i18n from './translations'; +import endpointPng from '../../images/endpoint.png'; +import siemPng from '../../images/siem.png'; +import videoSvg from '../../images/video.svg'; +import { ADD_DATA_PATH } from '../../../../common/constants'; +import { useKibana } from '../../../common/lib/kibana'; + +const imgUrls = { + siem: siemPng, + video: videoSvg, + endpoint: endpointPng, +}; + +const StyledEuiCard = styled(EuiCard)` + span.euiTitle { + font-size: 36px; + line-height: 100%; + } +`; +const StyledEuiCardTop = styled(EuiCard)` + span.euiTitle { + font-size: 36px; + line-height: 100%; + } + max-width: 600px; + display: block; + margin: 20px auto 0; +`; +const StyledEuiPageHeader = styled(EuiPageHeader)` + h1 { + font-size: 18px; + } +`; + +const StyledEuiImage = styled(EuiImage)` + img { + display: block; + margin: 0 auto; + } +`; + +const StyledImgEuiCard = styled(EuiCard)` + img { + margin-top: 20px; + max-width: 400px; + } +`; + +const StyledEuiFlexItem = styled(EuiFlexItem)` + background: ${({ theme }) => theme.eui.euiColorLightestShade}; + padding: 20px; + margin: -12px !important; +`; + +const ELASTIC_SECURITY_URL = `elastic.co/security`; + +export const LandingCards = memo(() => { + const { + http: { + basePath: { prepend }, + }, + } = useKibana().services; + + const tooltipContent = ( + + {ELASTIC_SECURITY_URL} + + ); + + const href = useMemo(() => prepend(ADD_DATA_PATH), [prepend]); + return ( + + + + + + + {i18n.SIEM_CTA} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + {i18n.SIEM_CTA} + + } + /> + + + ); +}); +LandingCards.displayName = 'LandingCards'; diff --git a/x-pack/plugins/security_solution/public/overview/components/landing_cards/translations.tsx b/x-pack/plugins/security_solution/public/overview/components/landing_cards/translations.tsx new file mode 100644 index 0000000000000..51da2e72c3bbd --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/landing_cards/translations.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SIEM_HEADER = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siem.header', + { + defaultMessage: 'Elastic Security', + } +); + +export const SIEM_TITLE = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siem.title', + { + defaultMessage: 'Security at the speed of Elastic', + } +); +export const SIEM_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siem.desc', + { + defaultMessage: + 'Elastic Security equips teams to prevent, detect, and respond to threats at cloud speed and scale — securing business operations with a unified, open platform.', + } +); +export const SIEM_CTA = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siem.cta', + { + defaultMessage: 'Add security integrations', + } +); +export const ENDPOINT_TITLE = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.endpoint.title', + { + defaultMessage: 'Endpoint security at scale', + } +); +export const ENDPOINT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.endpoint.desc', + { + defaultMessage: 'Prevent, collect, detect and respond -- all with Elastic Agent.', + } +); + +export const SIEM_CARD_TITLE = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siemCard.title', + { + defaultMessage: 'SIEM for the modern SOC', + } +); +export const SIEM_CARD_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siemCard.desc', + { + defaultMessage: 'Detect, investigate, and respond to evolving threats', + } +); + +export const UNIFY_TITLE = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.unify.title', + { + defaultMessage: 'Unify SIEM, endpoint security, and cloud security', + } +); +export const UNIFY_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.unify.desc', + { + defaultMessage: + 'Elastic Security modernizes security operations — enabling analytics across years of data, automating key processes, and bringing native endpoint security to every host.', + } +); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx index 36ecc3371c056..db157e9fc7135 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx @@ -6,71 +6,35 @@ */ import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; import { OverviewEmpty } from '.'; -import { useUserPrivileges } from '../../../common/components/user_privileges'; - -const endpointPackageVersion = '0.19.1'; - -jest.mock('../../../common/lib/kibana'); -jest.mock('../../../management/pages/endpoint_hosts/view/hooks', () => ({ - useIngestUrl: jest - .fn() - .mockReturnValue({ appId: 'ingestAppId', appPath: 'ingestPath', url: 'ingestUrl' }), - useEndpointSelector: jest.fn().mockReturnValue({ endpointPackageVersion }), -})); - -jest.mock('../../../common/components/user_privileges', () => ({ - useUserPrivileges: jest - .fn() - .mockReturnValue({ endpointPrivileges: { loading: false, canAccessFleet: true } }), -})); - -jest.mock('../../../common/hooks/endpoint/use_navigate_to_app_event_handler', () => ({ - useNavigateToAppEventHandler: jest.fn(), -})); - -describe('OverviewEmpty', () => { - describe('When isIngestEnabled = true', () => { - let wrapper: ShallowWrapper; - beforeAll(() => { - wrapper = shallow(); - }); - - afterAll(() => { - (useUserPrivileges as jest.Mock).mockReset(); - }); - - it('render with correct actions ', () => { - expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ - elasticAgent: { - category: 'security', - description: - 'Use Elastic Agent to collect security events and protect your endpoints from threats.', - title: 'Add a Security integration', +import { APP_UI_ID, SecurityPageName } from '../../../../common/constants'; +import { getAppLandingUrl } from '../../../common/components/link_to/redirect_to_overview'; + +const mockNavigateToApp = jest.fn(); +jest.mock('../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + navigateToApp: mockNavigateToApp, }, - }); - }); - }); - - describe('When isIngestEnabled = false', () => { - let wrapper: ShallowWrapper; - beforeAll(() => { - (useUserPrivileges as jest.Mock).mockReturnValue({ - endpointPrivileges: { loading: false, canAccessFleet: false }, - }); - wrapper = shallow(); - }); + }, + }), + }; +}); - it('render with correct actions ', () => { - expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ - elasticAgent: { - category: 'security', - description: - 'Use Elastic Agent to collect security events and protect your endpoints from threats.', - title: 'Add a Security integration', - }, - }); +describe('Redirect to landing page', () => { + it('render with correct actions ', () => { + shallow(); + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx index 023d010ec9a9b..91395aa21486f 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx @@ -6,39 +6,18 @@ */ import React from 'react'; -import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../common/lib/kibana'; -import { SOLUTION_NAME } from '../../../../public/common/translations'; - -import { - NoDataPage, - NoDataPageActionsProps, -} from '../../../../../../../src/plugins/kibana_react/public'; +import { APP_UI_ID, SecurityPageName } from '../../../../common/constants'; +import { getAppLandingUrl } from '../../../common/components/link_to/redirect_to_overview'; const OverviewEmptyComponent: React.FC = () => { - const { docLinks } = useKibana().services; - - const agentAction: NoDataPageActionsProps = { - elasticAgent: { - category: 'security', - title: i18n.translate('xpack.securitySolution.pages.emptyPage.beatsCard.title', { - defaultMessage: 'Add a Security integration', - }), - description: i18n.translate('xpack.securitySolution.pages.emptyPage.beatsCard.description', { - defaultMessage: - 'Use Elastic Agent to collect security events and protect your endpoints from threats.', - }), - }, - }; + const { navigateToApp } = useKibana().services.application; - return ( - - ); + navigateToApp(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); + return null; }; OverviewEmptyComponent.displayName = 'OverviewEmptyComponent'; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx index 1295693db506f..173710a7700e8 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx @@ -21,9 +21,12 @@ import { import { OverviewHost } from '.'; import { createStore, State } from '../../../common/store'; import { useHostOverview } from '../../containers/overview_host'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/components/link_to'); +jest.mock('../../../common/containers/query_toggle'); const startDate = '2020-01-20T20:49:57.080Z'; const endDate = '2020-01-21T20:49:57.080Z'; @@ -32,6 +35,7 @@ const testProps = { indexNames: [], setQuery: jest.fn(), startDate, + filterQuery: '', }; const MOCKED_RESPONSE = { overviewHost: { @@ -56,7 +60,7 @@ const MOCKED_RESPONSE = { jest.mock('../../containers/overview_host'); const useHostOverviewMock = useHostOverview as jest.Mock; -useHostOverviewMock.mockReturnValue([false, MOCKED_RESPONSE]); +const mockUseQueryToggle = useQueryToggle as jest.Mock; describe('OverviewHost', () => { const state: State = mockGlobalState; @@ -65,7 +69,10 @@ describe('OverviewHost', () => { let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); const myState = cloneDeep(state); + useHostOverviewMock.mockReturnValue([false, MOCKED_RESPONSE]); store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); @@ -103,4 +110,24 @@ describe('OverviewHost', () => { 'Showing: 16 events' ); }); + + test('toggleStatus=true, do not skip', () => { + const { queryByTestId } = render( + + + + ); + expect(useHostOverviewMock.mock.calls[0][0].skip).toEqual(false); + expect(queryByTestId('overview-hosts-stats')).toBeInTheDocument(); + }); + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + const { queryByTestId } = render( + + + + ); + expect(useHostOverviewMock.mock.calls[0][0].skip).toEqual(true); + expect(queryByTestId('overview-hosts-stats')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx index 32585c8836cc3..1bf990b755f65 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx @@ -9,7 +9,7 @@ import { isEmpty } from 'lodash/fp'; import { EuiFlexItem, EuiPanel } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useState, useEffect } from 'react'; import { DEFAULT_NUMBER_FORMAT, APP_UI_ID } from '../../../../common/constants'; import { ESQuery } from '../../../../common/typed_json'; @@ -23,6 +23,7 @@ import { InspectButtonContainer } from '../../../common/components/inspect'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { SecurityPageName } from '../../../app/types'; import { LinkButton } from '../../../common/components/links'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; export interface OwnProps { startDate: GlobalTimeArgs['from']; @@ -46,12 +47,26 @@ const OverviewHostComponent: React.FC = ({ const { navigateToApp } = useKibana().services.application; const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); + const { toggleStatus, setToggleStatus } = useQueryToggle(OverviewHostQueryId); + const [querySkip, setQuerySkip] = useState(filterQuery === undefined || !toggleStatus); + useEffect(() => { + setQuerySkip(filterQuery === undefined || !toggleStatus); + }, [filterQuery, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, { overviewHost, id, inspect, refetch }] = useHostOverview({ endDate, filterQuery, indexNames, startDate, - skip: filterQuery === undefined, + skip: querySkip, }); const goToHost = useCallback( @@ -116,25 +131,29 @@ const OverviewHostComponent: React.FC = ({ return ( - + <>{hostPageButton} - - + {toggleStatus && ( + + )} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx index dfc144be8e5bb..2293a0380f3a8 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx @@ -21,6 +21,8 @@ import { OverviewNetwork } from '.'; import { createStore, State } from '../../../common/store'; import { useNetworkOverview } from '../../containers/overview_network'; import { SecurityPageName } from '../../../app/types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; jest.mock('../../../common/components/link_to'); const mockNavigateToApp = jest.fn(); @@ -46,6 +48,7 @@ const startDate = '2020-01-20T20:49:57.080Z'; const endDate = '2020-01-21T20:49:57.080Z'; const defaultProps = { endDate, + filterQuery: '', startDate, setQuery: jest.fn(), indexNames: [], @@ -65,9 +68,10 @@ const MOCKED_RESPONSE = { }, }; +jest.mock('../../../common/containers/query_toggle'); jest.mock('../../containers/overview_network'); const useNetworkOverviewMock = useNetworkOverview as jest.Mock; -useNetworkOverviewMock.mockReturnValue([false, MOCKED_RESPONSE]); +const mockUseQueryToggle = useQueryToggle as jest.Mock; describe('OverviewNetwork', () => { const state: State = mockGlobalState; @@ -76,6 +80,9 @@ describe('OverviewNetwork', () => { let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); beforeEach(() => { + jest.clearAllMocks(); + useNetworkOverviewMock.mockReturnValue([false, MOCKED_RESPONSE]); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); const myState = cloneDeep(state); store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); @@ -143,4 +150,24 @@ describe('OverviewNetwork', () => { deepLinkId: SecurityPageName.network, }); }); + + it('toggleStatus=true, do not skip', () => { + const { queryByTestId } = render( + + + + ); + expect(useNetworkOverviewMock.mock.calls[0][0].skip).toEqual(false); + expect(queryByTestId('overview-network-stats')).toBeInTheDocument(); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + const { queryByTestId } = render( + + + + ); + expect(useNetworkOverviewMock.mock.calls[0][0].skip).toEqual(true); + expect(queryByTestId('overview-network-stats')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx index 7607a9eac4926..ce6c065d424d4 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx @@ -9,7 +9,7 @@ import { isEmpty } from 'lodash/fp'; import { EuiFlexItem, EuiPanel } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useState, useEffect } from 'react'; import { DEFAULT_NUMBER_FORMAT, APP_UI_ID } from '../../../../common/constants'; import { ESQuery } from '../../../../common/typed_json'; @@ -26,6 +26,7 @@ import { InspectButtonContainer } from '../../../common/components/inspect'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { SecurityPageName } from '../../../app/types'; import { LinkButton } from '../../../common/components/links'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; export interface OverviewNetworkProps { startDate: GlobalTimeArgs['from']; @@ -48,12 +49,26 @@ const OverviewNetworkComponent: React.FC = ({ const { navigateToApp } = useKibana().services.application; const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); + const { toggleStatus, setToggleStatus } = useQueryToggle(OverviewNetworkQueryId); + const [querySkip, setQuerySkip] = useState(filterQuery === undefined || !toggleStatus); + useEffect(() => { + setQuerySkip(filterQuery === undefined || !toggleStatus); + }, [filterQuery, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, { overviewNetwork, id, inspect, refetch }] = useNetworkOverview({ endDate, filterQuery, indexNames, startDate, - skip: filterQuery === undefined, + skip: querySkip, }); const goToNetwork = useCallback( @@ -121,26 +136,30 @@ const OverviewNetworkComponent: React.FC = ({ return ( - + <> {networkPageButton} - - + {toggleStatus && ( + + )} diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.test.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.test.tsx new file mode 100644 index 0000000000000..53f07d5195c26 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useHostOverview } from './index'; + +describe('useHostOverview', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useHostOverview(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx index 52b58439af0ab..b79169b1ac762 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx @@ -146,5 +146,12 @@ export const useHostOverview = ({ }; }, [overviewHostRequest, overviewHostSearch]); + useEffect(() => { + if (skip) { + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, overviewHostResponse]; }; diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.test.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.test.tsx new file mode 100644 index 0000000000000..64cc2e6bbd179 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkOverview } from './index'; + +describe('useNetworkOverview', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkOverview(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx index dd98a0ff03632..c2683b74a5b1a 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx @@ -147,5 +147,12 @@ export const useNetworkOverview = ({ }; }, [overviewNetworkRequest, overviewNetworkSearch]); + useEffect(() => { + if (skip) { + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, overviewNetworkResponse]; }; diff --git a/x-pack/plugins/security_solution/public/overview/images/endpoint.png b/x-pack/plugins/security_solution/public/overview/images/endpoint.png new file mode 100644 index 0000000000000..073318f891fcf Binary files /dev/null and b/x-pack/plugins/security_solution/public/overview/images/endpoint.png differ diff --git a/x-pack/plugins/security_solution/public/overview/images/siem.png b/x-pack/plugins/security_solution/public/overview/images/siem.png new file mode 100644 index 0000000000000..e5d6bee86a6cf Binary files /dev/null and b/x-pack/plugins/security_solution/public/overview/images/siem.png differ diff --git a/x-pack/plugins/security_solution/public/overview/images/video.svg b/x-pack/plugins/security_solution/public/overview/images/video.svg new file mode 100644 index 0000000000000..dc368823da04c --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/images/video.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/x-pack/plugins/security_solution/public/overview/pages/landing.tsx b/x-pack/plugins/security_solution/public/overview/pages/landing.tsx new file mode 100644 index 0000000000000..0554f1f51c28a --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/pages/landing.tsx @@ -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 React, { memo } from 'react'; +import { SpyRoute } from '../../common/utils/route/spy_routes'; +import { SecurityPageName } from '../../../common/constants'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; +import { LandingCards } from '../components/landing_cards'; + +export const LandingPage = memo(() => { + return ( + <> + + + + + + ); +}); + +LandingPage.displayName = 'LandingPage'; diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index da36e19d20a55..e5be86a1c9f91 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -27,8 +27,30 @@ import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experime import { initialUserPrivilegesState } from '../../common/components/user_privileges/user_privileges_context'; import { EndpointPrivileges } from '../../../common/endpoint/types'; import { useHostRiskScore } from '../../risk_score/containers'; +import { APP_UI_ID, SecurityPageName } from '../../../common/constants'; +import { getAppLandingUrl } from '../../common/components/link_to/redirect_to_overview'; +import { mockCasesContract } from '../../../../cases/public/mocks'; -jest.mock('../../common/lib/kibana'); +const mockNavigateToApp = jest.fn(); +jest.mock('../../common/lib/kibana', () => { + const original = jest.requireActual('../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + navigateToApp: mockNavigateToApp, + }, + cases: { + ...mockCasesContract(), + }, + }, + }), + }; +}); jest.mock('../../common/containers/source'); jest.mock('../../common/containers/sourcerer'); jest.mock('../../common/containers/use_global_time', () => ({ @@ -129,6 +151,9 @@ describe('Overview', () => { }); describe('rendering', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test('it DOES NOT render the Getting started text when an index is available', () => { mockUseSourcererDataView.mockReturnValue({ selectedPatterns: [], @@ -146,7 +171,7 @@ describe('Overview', () => { ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); + expect(mockNavigateToApp).not.toHaveBeenCalled(); wrapper.unmount(); }); @@ -279,14 +304,18 @@ describe('Overview', () => { }); it('renders the Setup Instructions text', () => { - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); + + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/routes.tsx b/x-pack/plugins/security_solution/public/overview/routes.tsx index c4fc3a6678c51..b4aa19e1e9bc1 100644 --- a/x-pack/plugins/security_solution/public/overview/routes.tsx +++ b/x-pack/plugins/security_solution/public/overview/routes.tsx @@ -7,9 +7,15 @@ import React from 'react'; import { TrackApplicationView } from '../../../../../src/plugins/usage_collection/public'; -import { OVERVIEW_PATH, DETECTION_RESPONSE_PATH, SecurityPageName } from '../../common/constants'; +import { + LANDING_PATH, + OVERVIEW_PATH, + DETECTION_RESPONSE_PATH, + SecurityPageName, +} from '../../common/constants'; import { SecuritySubPluginRoutes } from '../app/types'; +import { LandingPage } from './pages/landing'; import { StatefulOverview } from './pages/overview'; import { DetectionResponse } from './pages/detection_response'; @@ -24,6 +30,11 @@ const DetectionResponseRoutes = () => ( ); +const LandingRoutes = () => ( + + + +); export const routes: SecuritySubPluginRoutes = [ { @@ -34,4 +45,8 @@ export const routes: SecuritySubPluginRoutes = [ path: DETECTION_RESPONSE_PATH, render: DetectionResponseRoutes, }, + { + path: LANDING_PATH, + render: LandingRoutes, + }, ]; diff --git a/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx b/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx index b04d9dd05f283..8c95a081b3e86 100644 --- a/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx @@ -266,5 +266,13 @@ export const useRiskScore = { + if (skip) { + setLoading(false); + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, riskScoreResponse]; }; diff --git a/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.test.tsx b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.test.tsx new file mode 100644 index 0000000000000..6425f40016fb9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { TotalUsersKpi } from './index'; +import { useSearchStrategy } from '../../../../common/containers/use_search_strategy'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../../common/containers/use_search_strategy'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Total Users KPI', () => { + const mockUseSearchStrategy = useSearchStrategy as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + const mockSearch = jest.fn(); + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseSearchStrategy.mockReturnValue({ + result: [], + loading: false, + inspect: { + dsl: [], + response: [], + }, + search: mockSearch, + refetch: jest.fn(), + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseSearchStrategy.mock.calls[0][0].skip).toEqual(false); + expect(mockSearch).toHaveBeenCalled(); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseSearchStrategy.mock.calls[0][0].skip).toEqual(true); + expect(mockSearch).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx index 043c6b472497e..ffa5d851875ce 100644 --- a/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx +++ b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx @@ -6,7 +6,7 @@ */ import { euiPaletteColorBlind } from '@elastic/eui'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { UsersQueries } from '../../../../../common/search_strategy/security_solution/users'; import { UpdateDateRange } from '../../../../common/components/charts/common'; @@ -17,6 +17,7 @@ import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/c import { kpiTotalUsersMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric'; import { kpiTotalUsersAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/users/kpi_total_users_area'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; const euiVisColorPalette = euiPaletteColorBlind(); const euiColorVis1 = euiVisColorPalette[1]; @@ -60,15 +61,21 @@ const TotalUsersKpiComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(UsersQueries.kpiTotalUsers); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const { loading, result, search, refetch, inspect } = useSearchStrategy({ factoryQueryType: UsersQueries.kpiTotalUsers, initialResult: { users: 0, usersHistogram: [] }, errorMessage: i18n.ERROR_USERS_KPI, + skip: querySkip, }); useEffect(() => { - if (!skip) { + if (!querySkip) { search({ filterQuery, defaultIndex: indexNames, @@ -79,7 +86,7 @@ const TotalUsersKpiComponent: React.FC = ({ }, }); } - }, [search, from, to, filterQuery, indexNames, skip]); + }, [search, from, to, filterQuery, indexNames, querySkip]); return ( = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx index 3faa96b436de0..c0cd2e351298e 100644 --- a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx @@ -14,7 +14,7 @@ import { UsersType } from '../../store/model'; describe('UserRiskScoreTable', () => { const username = 'test_user_name'; - const defautProps = { + const defaultProps = { data: [ { '@timestamp': '1641902481', @@ -32,6 +32,7 @@ describe('UserRiskScoreTable', () => { isInspect: false, loading: false, loadPage: noop, + setQuerySkip: jest.fn(), severityCount: { Unknown: 0, Low: 0, @@ -46,7 +47,7 @@ describe('UserRiskScoreTable', () => { it('renders', () => { const { queryByTestId } = render( - + ); diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx index 9f782b7f28662..0b87165cbe8ac 100644 --- a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx @@ -57,6 +57,7 @@ interface UserRiskScoreTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; severityCount: SeverityCount; totalCount: number; type: usersModel.UsersType; @@ -74,6 +75,7 @@ const UserRiskScoreTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, severityCount, totalCount, type, @@ -118,6 +120,7 @@ const UserRiskScoreTableComponent: React.FC = ({ dispatch( usersActions.updateTableSorting({ sort: newSort as RiskScoreSortField, + tableType, }) ); } @@ -210,6 +213,7 @@ const UserRiskScoreTableComponent: React.FC = ({ loadPage={loadPage} onChange={onSort} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={false} sorting={sort} split={true} diff --git a/x-pack/plugins/security_solution/public/users/pages/constants.ts b/x-pack/plugins/security_solution/public/users/pages/constants.ts index 95c0e361e82d8..793d7c6164b2d 100644 --- a/x-pack/plugins/security_solution/public/users/pages/constants.ts +++ b/x-pack/plugins/security_solution/public/users/pages/constants.ts @@ -10,6 +10,6 @@ import { UsersTableType } from '../store/model'; export const usersDetailsPagePath = `${USERS_PATH}/:detailName`; -export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers}|${UsersTableType.anomalies}|${UsersTableType.risk})`; +export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers}|${UsersTableType.anomalies}|${UsersTableType.risk}|${UsersTableType.events}|${UsersTableType.alerts})`; -export const usersDetailsTabPath = `${usersDetailsPagePath}/:tabName(${UsersTableType.anomalies})`; +export const usersDetailsTabPath = `${usersDetailsPagePath}/:tabName(${UsersTableType.anomalies}|${UsersTableType.events}|${UsersTableType.alerts})`; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx index 966fe067fde88..25ada310b74b7 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Route, Switch } from 'react-router-dom'; import { UsersTableType } from '../../store/model'; @@ -16,6 +16,10 @@ import { scoreIntervalToDateTime } from '../../../common/components/ml/score/sco import { UpdateDateRange } from '../../../common/components/charts/common'; import { Anomaly } from '../../../common/components/ml/types'; import { usersDetailsPagePath } from '../constants'; +import { TimelineId } from '../../../../common/types'; +import { EventsQueryTabBody } from '../../../common/components/events_tab/events_query_tab_body'; +import { AlertsView } from '../../../common/components/alerts_viewer'; +import { filterUserExternalAlertData } from './helpers'; export const UsersDetailsTabs = React.memo( ({ @@ -29,6 +33,7 @@ export const UsersDetailsTabs = React.memo( type, setAbsoluteRangeDatePicker, detailName, + pageFilters, }) => { const narrowDateRange = useCallback( (score: Anomaly, interval: string) => { @@ -57,6 +62,14 @@ export const UsersDetailsTabs = React.memo( [setAbsoluteRangeDatePicker] ); + const alertsPageFilters = useMemo( + () => + pageFilters != null + ? [...filterUserExternalAlertData, ...pageFilters] + : filterUserExternalAlertData, + [pageFilters] + ); + const tabProps = { deleteQuery, endDate: to, @@ -76,6 +89,22 @@ export const UsersDetailsTabs = React.memo( + + + + + + + ); } diff --git a/x-pack/plugins/security_solution/public/users/pages/details/helpers.ts b/x-pack/plugins/security_solution/public/users/pages/details/helpers.ts index c96d21d3110e4..daa02df2fb9ca 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/helpers.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/helpers.ts @@ -30,3 +30,35 @@ export const getUsersDetailsPageFilters = (userName: string): Filter[] => [ }, }, ]; + +export const filterUserExternalAlertData: Filter[] = [ + { + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + exists: { + field: 'user.name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + meta: { + alias: '', + disabled: false, + key: 'bool', + negate: false, + type: 'custom', + value: + '{"query": {"bool": {"filter": [{"bool": {"should": [{"exists": {"field": "user.name"}}],"minimum_should_match": 1}}]}}}', + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/index.tsx b/x-pack/plugins/security_solution/public/users/pages/details/index.tsx index e68c37d6b4042..36ace6a6b4543 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/index.tsx @@ -50,6 +50,8 @@ import { useQueryInspector } from '../../../common/components/page/manage_query' import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime'; import { getCriteriaFromUsersType } from '../../../common/components/ml/criteria/get_criteria_from_users_type'; import { UsersType } from '../../store/model'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; const QUERY_ID = 'UsersDetailsQueryId'; const UsersDetailsComponent: React.FC = ({ @@ -110,6 +112,8 @@ const UsersDetailsComponent: React.FC = ({ skip: selectedPatterns.length === 0, }); + const capabilities = useMlCapabilities(); + useQueryInspector({ setQuery, deleteQuery, refetch, inspect, loading, queryId: QUERY_ID }); return ( @@ -165,7 +169,9 @@ const UsersDetailsComponent: React.FC = ({ - + diff --git a/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx index 47bc406876c22..9671bd4ee38d0 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { omit } from 'lodash/fp'; import * as i18n from '../translations'; import { UsersDetailsNavTab } from './types'; import { UsersTableType } from '../../store/model'; @@ -13,13 +14,32 @@ import { USERS_PATH } from '../../../../common/constants'; const getTabsOnUsersDetailsUrl = (userName: string, tabName: UsersTableType) => `${USERS_PATH}/${userName}/${tabName}`; -export const navTabsUsersDetails = (userName: string): UsersDetailsNavTab => { - return { +export const navTabsUsersDetails = ( + userName: string, + hasMlUserPermissions: boolean +): UsersDetailsNavTab => { + const userDetailsNavTabs = { [UsersTableType.anomalies]: { id: UsersTableType.anomalies, name: i18n.NAVIGATION_ANOMALIES_TITLE, href: getTabsOnUsersDetailsUrl(userName, UsersTableType.anomalies), disabled: false, }, + [UsersTableType.events]: { + id: UsersTableType.events, + name: i18n.NAVIGATION_EVENTS_TITLE, + href: getTabsOnUsersDetailsUrl(userName, UsersTableType.events), + disabled: false, + }, + [UsersTableType.alerts]: { + id: UsersTableType.alerts, + name: i18n.NAVIGATION_ALERTS_TITLE, + href: getTabsOnUsersDetailsUrl(userName, UsersTableType.alerts), + disabled: false, + }, }; + + return hasMlUserPermissions + ? userDetailsNavTabs + : omit([UsersTableType.anomalies], userDetailsNavTabs); }; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/types.ts b/x-pack/plugins/security_solution/public/users/pages/details/types.ts index 69974678bf4d9..1608d4b735b59 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/types.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/types.ts @@ -44,7 +44,15 @@ export type UsersDetailsComponentProps = UsersDetailsComponentReduxProps & UsersDetailsComponentDispatchProps & UsersQueryProps; -type KeyUsersDetailsNavTab = UsersTableType.anomalies; +export type KeyUsersDetailsNavTabWithoutMlPermission = UsersTableType.events & + UsersTableType.alerts; + +type KeyUsersDetailsNavTabWithMlPermission = KeyUsersDetailsNavTabWithoutMlPermission & + UsersTableType.anomalies; + +type KeyUsersDetailsNavTab = + | KeyUsersDetailsNavTabWithoutMlPermission + | KeyUsersDetailsNavTabWithMlPermission; export type UsersDetailsNavTab = Record; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/utils.ts b/x-pack/plugins/security_solution/public/users/pages/details/utils.ts index eb2820c6d4869..f4bdd7e6caa67 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/utils.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/utils.ts @@ -24,6 +24,8 @@ const TabNameMappedToI18nKey: Record = { [UsersTableType.allUsers]: i18n.NAVIGATION_ALL_USERS_TITLE, [UsersTableType.anomalies]: i18n.NAVIGATION_ANOMALIES_TITLE, [UsersTableType.risk]: i18n.NAVIGATION_RISK_TITLE, + [UsersTableType.events]: i18n.NAVIGATION_EVENTS_TITLE, + [UsersTableType.alerts]: i18n.NAVIGATION_ALERTS_TITLE, }; export const getBreadcrumbs = ( diff --git a/x-pack/plugins/security_solution/public/users/pages/index.tsx b/x-pack/plugins/security_solution/public/users/pages/index.tsx index 0b6b103b78176..f1f4e545ae9fd 100644 --- a/x-pack/plugins/security_solution/public/users/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/index.tsx @@ -12,44 +12,53 @@ import { UsersTableType } from '../store/model'; import { Users } from './users'; import { UsersDetails } from './details'; import { usersDetailsPagePath, usersDetailsTabPath, usersTabPath } from './constants'; +import { useMlCapabilities } from '../../common/components/ml/hooks/use_ml_capabilities'; +import { hasMlUserPermissions } from '../../../common/machine_learning/has_ml_user_permissions'; -export const UsersContainer = React.memo(() => ( - - - - +export const UsersContainer = React.memo(() => { + const capabilities = useMlCapabilities(); + const hasMlPermissions = hasMlUserPermissions(capabilities); - } - /> - ( - - )} - /> - ( - - )} - /> - -)); + return ( + + + + + + } + /> + ( + + )} + /> + ( + + )} + /> + + ); +}); UsersContainer.displayName = 'UsersContainer'; diff --git a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx index 35124d1deddb1..254807eae27cc 100644 --- a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx @@ -38,6 +38,18 @@ export const navTabsUsers = ( href: getTabsOnUsersUrl(UsersTableType.risk), disabled: false, }, + [UsersTableType.events]: { + id: UsersTableType.events, + name: i18n.NAVIGATION_EVENTS_TITLE, + href: getTabsOnUsersUrl(UsersTableType.events), + disabled: false, + }, + [UsersTableType.alerts]: { + id: UsersTableType.alerts, + name: i18n.NAVIGATION_ALERTS_TITLE, + href: getTabsOnUsersUrl(UsersTableType.alerts), + disabled: false, + }, }; if (!hasMlUserPermissions) { diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.test.tsx new file mode 100644 index 0000000000000..98b69d531c4dc --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useAuthentications } from '../../../hosts/containers/authentications'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { AllUsersQueryTabBody } from './all_users_query_tab_body'; +import { UsersType } from '../../store/model'; + +jest.mock('../../../hosts/containers/authentications'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('All users query tab body', () => { + const mockUseAuthentications = useAuthentications as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: UsersType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseAuthentications.mockReturnValue([ + false, + { + authentications: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx index 6c494c9752c4f..b5c8b199fda54 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx @@ -6,12 +6,13 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; -import { useAuthentications } from '../../../hosts/containers/authentications'; +import React, { useEffect, useState } from 'react'; +import { useAuthentications, ID } from '../../../hosts/containers/authentications'; import { UsersComponentsQueryProps } from './types'; import { AuthenticationTable } from '../../../hosts/components/authentications_table'; import { manageQuery } from '../../../common/components/page/manage_query'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const AuthenticationTableManage = manageQuery(AuthenticationTable); @@ -26,6 +27,11 @@ export const AllUsersQueryTabBody = ({ docValueFields, deleteQuery, }: UsersComponentsQueryProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { authentications, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, @@ -34,14 +40,13 @@ export const AllUsersQueryTabBody = ({ endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, - // TODO Fix me + // TODO Move authentication table and hook store to 'public/common' folder when 'usersEnabled' FF is removed // @ts-ignore type, deleteQuery, }); - // TODO Use a different table return ( diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts b/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts index f3fd099d78548..d5c49590dad60 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts @@ -10,7 +10,11 @@ import { ESTermQuery } from '../../../../common/typed_json'; import { DocValueFields } from '../../../../../timelines/common'; import { NavTab } from '../../../common/components/navigation/types'; -type KeyUsersNavTabWithoutMlPermission = UsersTableType.allUsers & UsersTableType.risk; +type KeyUsersNavTabWithoutMlPermission = UsersTableType.allUsers & + UsersTableType.risk & + UsersTableType.events & + UsersTableType.alerts; + type KeyUsersNavTabWithMlPermission = KeyUsersNavTabWithoutMlPermission & UsersTableType.anomalies; type KeyUsersNavTab = KeyUsersNavTabWithoutMlPermission | KeyUsersNavTabWithMlPermission; diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx new file mode 100644 index 0000000000000..6b5ec66f864bb --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useUserRiskScore, useUserRiskScoreKpi } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { UserRiskScoreQueryTabBody } from './user_risk_score_tab_body'; +import { UsersType } from '../../store/model'; + +jest.mock('../../../risk_score/containers'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('All users query tab body', () => { + const mockUseUserRiskScore = useUserRiskScore as jest.Mock; + const mockUseUserRiskScoreKpi = useUserRiskScoreKpi as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: UsersType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseUserRiskScore.mockReturnValue([ + false, + { + authentications: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + mockUseUserRiskScoreKpi.mockReturnValue({ + loading: false, + severityCount: { + unknown: 12, + low: 12, + moderate: 12, + high: 12, + critical: 12, + }, + }); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(false); + expect(mockUseUserRiskScoreKpi.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(true); + expect(mockUseUserRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx index a19e7803cb90f..a479788ce0f41 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { noop } from 'lodash/fp'; import { UsersComponentsQueryProps } from './types'; @@ -20,6 +20,7 @@ import { useUserRiskScore, useUserRiskScoreKpi, } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const UserRiskScoreTableManage = manageQuery(UserRiskScoreTable); @@ -43,15 +44,22 @@ export const UserRiskScoreQueryTabBody = ({ [activePage, limit] ); + const { toggleStatus } = useQueryToggle(UserRiskScoreQueryId.USERS_BY_RISK); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const [loading, { data, totalCount, inspect, isInspected, refetch }] = useUserRiskScore({ filterQuery, - skip, + skip: querySkip, pagination, sort, }); const { severityCount, loading: isKpiLoading } = useUserRiskScoreKpi({ filterQuery, + skip: querySkip, }); return ( @@ -65,6 +73,7 @@ export const UserRiskScoreQueryTabBody = ({ loadPage={noop} // It isn't necessary because PaginatedTable updates redux store and we load the page when activePage updates on the store refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} severityCount={severityCount} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/users/pages/translations.ts b/x-pack/plugins/security_solution/public/users/pages/translations.ts index 7744ef125ffa2..96dcf8d2c8871 100644 --- a/x-pack/plugins/security_solution/public/users/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/users/pages/translations.ts @@ -31,3 +31,17 @@ export const NAVIGATION_RISK_TITLE = i18n.translate( defaultMessage: 'Users by risk', } ); + +export const NAVIGATION_EVENTS_TITLE = i18n.translate( + 'xpack.securitySolution.users.navigation.eventsTitle', + { + defaultMessage: 'Events', + } +); + +export const NAVIGATION_ALERTS_TITLE = i18n.translate( + 'xpack.securitySolution.users.navigation.alertsTitle', + { + defaultMessage: 'External alerts', + } +); diff --git a/x-pack/plugins/security_solution/public/users/pages/users.tsx b/x-pack/plugins/security_solution/public/users/pages/users.tsx index bd6cc2d097c46..6acd2ddf32a3c 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users.tsx @@ -162,6 +162,10 @@ const UsersComponent = () => { const capabilities = useMlCapabilities(); const riskyUsersFeatureEnabled = useIsExperimentalFeatureEnabled('riskyUsersEnabled'); + const navTabs = useMemo( + () => navTabsUsers(hasMlUserPermissions(capabilities), riskyUsersFeatureEnabled), + [capabilities, riskyUsersFeatureEnabled] + ); return ( <> @@ -197,9 +201,7 @@ const UsersComponent = () => { - + diff --git a/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx b/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx index 41cb19e48e94d..e3807f359a0ff 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx @@ -15,6 +15,8 @@ import { SecuritySolutionTabNavigation } from '../../common/components/navigatio import { Users } from './users'; import { useSourcererDataView } from '../../common/containers/sourcerer'; import { mockCasesContext } from '../../../../cases/public/mocks/mock_cases_context'; +import { APP_UI_ID, SecurityPageName } from '../../../common/constants'; +import { getAppLandingUrl } from '../../common/components/link_to/redirect_to_overview'; jest.mock('../../common/containers/sourcerer'); jest.mock('../../common/components/search_bar', () => ({ @@ -26,6 +28,7 @@ jest.mock('../../common/components/query_bar', () => ({ jest.mock('../../common/components/visualization_actions', () => ({ VisualizationActions: jest.fn(() =>
), })); +const mockNavigateToApp = jest.fn(); jest.mock('../../common/lib/kibana', () => { const original = jest.requireActual('../../common/lib/kibana'); @@ -34,6 +37,10 @@ jest.mock('../../common/lib/kibana', () => { useKibana: () => ({ services: { ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + navigateToApp: mockNavigateToApp, + }, cases: { ui: { getCasesContext: jest.fn().mockReturnValue(mockCasesContext), @@ -71,14 +78,17 @@ describe('Users - rendering', () => { indicesExist: false, }); - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); }); test('it should render tab navigation', async () => { diff --git a/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx index 50de49d1e4af1..522ff4c009504 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx @@ -19,6 +19,9 @@ import { scoreIntervalToDateTime } from '../../common/components/ml/score/score_ import { UpdateDateRange } from '../../common/components/charts/common'; import { UserRiskScoreQueryTabBody } from './navigation/user_risk_score_tab_body'; +import { EventsQueryTabBody } from '../../common/components/events_tab/events_query_tab_body'; +import { TimelineId } from '../../../common/types'; +import { AlertsView } from '../../common/components/alerts_viewer'; export const UsersTabs = memo( ({ @@ -83,6 +86,17 @@ export const UsersTabs = memo( + + + + + + ); } diff --git a/x-pack/plugins/security_solution/public/users/store/actions.ts b/x-pack/plugins/security_solution/public/users/store/actions.ts index 262604f68bdf5..b1d83f29da8c8 100644 --- a/x-pack/plugins/security_solution/public/users/store/actions.ts +++ b/x-pack/plugins/security_solution/public/users/store/actions.ts @@ -31,6 +31,7 @@ export const updateTableActivePage = actionCreator<{ export const updateTableSorting = actionCreator<{ sort: RiskScoreSortField; + tableType: usersModel.UsersTableType.risk; }>('UPDATE_USERS_SORTING'); export const updateUserRiskScoreSeverityFilter = actionCreator<{ diff --git a/x-pack/plugins/security_solution/public/users/store/model.ts b/x-pack/plugins/security_solution/public/users/store/model.ts index 22630d34d48a8..6e4a3730eca86 100644 --- a/x-pack/plugins/security_solution/public/users/store/model.ts +++ b/x-pack/plugins/security_solution/public/users/store/model.ts @@ -16,6 +16,8 @@ export enum UsersTableType { allUsers = 'allUsers', anomalies = 'anomalies', risk = 'userRisk', + events = 'events', + alerts = 'externalAlerts', } export type AllUsersTables = UsersTableType; @@ -36,10 +38,14 @@ export interface UsersQueries { [UsersTableType.allUsers]: AllUsersQuery; [UsersTableType.anomalies]: null | undefined; [UsersTableType.risk]: UsersRiskScoreQuery; + [UsersTableType.events]: BasicQueryPaginated; + [UsersTableType.alerts]: BasicQueryPaginated; } export interface UserDetailsQueries { [UsersTableType.anomalies]: null | undefined; + [UsersTableType.events]: BasicQueryPaginated; + [UsersTableType.alerts]: BasicQueryPaginated; } export interface UsersPageModel { diff --git a/x-pack/plugins/security_solution/public/users/store/reducer.ts b/x-pack/plugins/security_solution/public/users/store/reducer.ts index 26b2e8a225d5a..4b263eecb8c5a 100644 --- a/x-pack/plugins/security_solution/public/users/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/users/store/reducer.ts @@ -37,11 +37,27 @@ export const initialUsersState: UsersModel = { severitySelection: [], }, [UsersTableType.anomalies]: null, + [UsersTableType.events]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + }, + [UsersTableType.alerts]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + }, }, }, details: { queries: { [UsersTableType.anomalies]: null, + [UsersTableType.events]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + }, + [UsersTableType.alerts]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + }, }, }, }; @@ -80,14 +96,14 @@ export const usersReducer = reducerWithInitialState(initialUsersState) }, }, })) - .case(updateTableSorting, (state, { sort }) => ({ + .case(updateTableSorting, (state, { sort, tableType }) => ({ ...state, page: { ...state.page, queries: { ...state.page.queries, - [UsersTableType.risk]: { - ...state.page.queries[UsersTableType.risk], + [tableType]: { + ...state.page.queries[tableType], sort, }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index 83dbcf1ca6f6d..179ea3827df0c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -513,6 +513,622 @@ describe('artifacts lists', () => { }); }); + describe('Endpoint Artifacts', () => { + const getOsFilter = (os: 'macos' | 'linux' | 'windows') => + `exception-list-agnostic.attributes.os_types:"${os} "`; + + describe('linux', () => { + test('it should add process.name entry when wildcard process.executable entry has filename', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value: '/usr/bi*/doc.md', + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bi*/doc.md', + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_cased', + value: 'doc.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should add file.name entry when wildcard file.path.text entry has filename', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: '/usr/bi*/doc.md', + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bi*/doc.md', + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_cased', + value: 'doc.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add process.name entry when wildcard process.executable entry has wildcard filename', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value: '/usr/bin/*.md', + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bin/*.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add process.name entry when process.name entry already exists', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value: '/usr/bi*/donotadd.md', + }, + { + field: 'process.name', + operator: 'included', + type: 'match', + value: 'appname.exe', + }, + { + field: 'process.name', + operator: 'included', + type: 'match_any', + value: ['one.exe', 'two.exe'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bi*/donotadd.md', + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_cased', + value: 'appname.exe', + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_cased_any', + value: ['one.exe', 'two.exe'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add file.name entry when wildcard file.path.text entry has wildcard filename', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: '/usr/bin/*.md', + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bin/*.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add file.name entry when file.name entry already exists', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: '/usr/b*/donotadd.md', + }, + { + field: 'file.name', + operator: 'included', + type: 'match', + value: 'filename.app', + }, + { + field: 'file.name', + operator: 'included', + type: 'match_any', + value: ['one.app', 'two.app'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/b*/donotadd.md', + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_cased', + value: 'filename.app', + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_cased_any', + value: ['one.app', 'two.app'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + }); + + describe('macos/windows', () => { + test('it should add process.name entry for process.executable entry with wildcard type', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\doc.md' : '/usr/bi*/doc.md'; + + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value, + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_caseless', + value: 'doc.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should add file.name entry when wildcard file.path.text entry has filename', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\doc.md' : '/usr/bi*/doc.md'; + + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value, + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_caseless', + value: 'doc.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add process.name entry when wildcard process.executable entry has wildcard filename', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\*.md' : '/usr/bin/*.md'; + + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value, + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add process.name entry when process.name entry already exists', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\donotadd.md' : '/usr/bin/donotadd.md'; + + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value, + }, + { + field: 'process.name', + operator: 'included', + type: 'match', + value: 'appname.exe', + }, + { + field: 'process.name', + operator: 'included', + type: 'match_any', + value: ['one.exe', 'two.exe'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_caseless', + value: 'appname.exe', + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_caseless_any', + value: ['one.exe', 'two.exe'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add file.name entry when wildcard file.path.text entry has wildcard filename', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\*.md' : '/usr/bin/*.md'; + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value, + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add file.name entry when file.name entry already exists', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\donotadd.md' : '/usr/bin/donotadd.md'; + + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value, + }, + { + field: 'file.name', + operator: 'included', + type: 'match', + value: 'filename.app', + }, + { + field: 'file.name', + operator: 'included', + type: 'match_any', + value: ['one.app', 'two.app'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_caseless', + value: 'filename.app', + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_caseless_any', + value: ['one.app', 'two.app'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + }); + }); + const TEST_EXCEPTION_LIST_ITEM = { entries: [ { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 7521ccbf9df91..2ea52485e625b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -187,11 +187,16 @@ function getMatcherFunction({ matchAny?: boolean; os: ExceptionListItemSchema['os_types'][number]; }): TranslatedEntryMatcher { + const doesFieldEndWith: boolean = + field.endsWith('.caseless') || field.endsWith('.name') || field.endsWith('.text'); + return matchAny - ? field.endsWith('.caseless') && os !== 'linux' - ? 'exact_caseless_any' + ? doesFieldEndWith + ? os === 'linux' + ? 'exact_cased_any' + : 'exact_caseless_any' : 'exact_cased_any' - : field.endsWith('.caseless') + : doesFieldEndWith ? os === 'linux' ? 'exact_cased' : 'exact_caseless' @@ -213,7 +218,9 @@ function getMatcherWildcardFunction({ } function normalizeFieldName(field: string): string { - return field.endsWith('.caseless') ? field.substring(0, field.lastIndexOf('.')) : field; + return field.endsWith('.caseless') || field.endsWith('.text') + ? field.substring(0, field.lastIndexOf('.')) + : field; } function translateItem( @@ -223,7 +230,7 @@ function translateItem( const itemSet = new Set(); const getEntries = (): TranslatedExceptionListItem['entries'] => { return item.entries.reduce((translatedEntries, entry) => { - const translatedEntry = translateEntry(schemaVersion, entry, item.os_types[0]); + const translatedEntry = translateEntry(schemaVersion, item.entries, entry, item.os_types[0]); if (translatedEntry !== undefined) { if (translatedEntryType.is(translatedEntry)) { @@ -256,12 +263,11 @@ function translateItem( }; } -function appendProcessNameEntry({ - wildcardProcessEntry, +function appendOptimizedEntryForEndpoint({ entry, os, + wildcardProcessEntry, }: { - wildcardProcessEntry: TranslatedEntryMatchWildcard; entry: { field: string; operator: 'excluded' | 'included'; @@ -269,11 +275,15 @@ function appendProcessNameEntry({ value: string; }; os: ExceptionListItemSchema['os_types'][number]; + wildcardProcessEntry: TranslatedEntryMatchWildcard; }): TranslatedPerformantEntries { const entries: TranslatedPerformantEntries = [ wildcardProcessEntry, { - field: normalizeFieldName('process.name'), + field: + entry.field === 'file.path.text' + ? normalizeFieldName('file.name') + : normalizeFieldName('process.name'), operator: entry.operator, type: (os === 'linux' ? 'exact_cased' : 'exact_caseless') as Extract< TranslatedEntryMatcher, @@ -291,6 +301,7 @@ function appendProcessNameEntry({ function translateEntry( schemaVersion: string, + exceptionListItemEntries: ExceptionListItemSchema['entries'], entry: Entry | EntryNested, os: ExceptionListItemSchema['os_types'][number] ): TranslatedEntry | TranslatedPerformantEntries | undefined { @@ -298,7 +309,12 @@ function translateEntry( case 'nested': { const nestedEntries = entry.entries.reduce( (entries, nestedEntry) => { - const translatedEntry = translateEntry(schemaVersion, nestedEntry, os); + const translatedEntry = translateEntry( + schemaVersion, + exceptionListItemEntries, + nestedEntry, + os + ); if (nestedEntry !== undefined && translatedEntryNestedEntry.is(translatedEntry)) { entries.push(translatedEntry); } @@ -354,11 +370,21 @@ function translateEntry( type: entry.type, value: entry.value, }); - if (hasExecutableName) { + + const existingFields = exceptionListItemEntries.map((e) => e.field); + const doAddPerformantEntries = !( + existingFields.includes('process.name') || existingFields.includes('file.name') + ); + + if (hasExecutableName && doAddPerformantEntries) { // when path has a full executable name // append a process.name entry based on os // `exact_cased` for linux and `exact_caseless` for others - return appendProcessNameEntry({ entry, os, wildcardProcessEntry }); + return appendOptimizedEntryForEndpoint({ + entry, + os, + wildcardProcessEntry, + }); } else { return wildcardProcessEntry; } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 7be2a36396a71..a8c63bbb88e13 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -31,6 +31,7 @@ import { getArtifactId, getEndpointExceptionList, Manifest, + ArtifactListId, } from '../../../lib/artifacts'; import { InternalArtifactCompleteSchema, @@ -48,6 +49,11 @@ interface ArtifactsBuildResult { policySpecificArtifacts: Record; } +interface BuildArtifactsForOsOptions { + listId: ArtifactListId; + name: string; +} + const iterateArtifactsBuildResult = async ( result: ArtifactsBuildResult, callback: (artifact: InternalArtifactCompleteSchema, policyId?: string) => Promise @@ -174,20 +180,29 @@ export class ManifestManager { /** * Builds an artifact (one per supported OS) based on the current state of the - * Trusted Apps list (which uses the `exception-list-agnostic` SO type) + * artifacts list (Trusted Apps, Host Iso. Exceptions, Event Filters, Blocklists) + * (which uses the `exception-list-agnostic` SO type) */ - protected async buildTrustedAppsArtifact(os: string, policyId?: string) { + protected async buildArtifactsForOs({ + listId, + name, + os, + policyId, + }: { + os: string; + policyId?: string; + } & BuildArtifactsForOsOptions): Promise { return buildArtifact( await getEndpointExceptionList({ elClient: this.exceptionListClient, schemaVersion: this.schemaVersion, os, policyId, - listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + listId, }), this.schemaVersion, os, - ArtifactConstants.GLOBAL_TRUSTED_APPS_NAME + name ); } @@ -198,9 +213,13 @@ export class ManifestManager { protected async buildTrustedAppsArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; + const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = { + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + name: ArtifactConstants.GLOBAL_TRUSTED_APPS_NAME, + }; for (const os of ArtifactConstants.SUPPORTED_TRUSTED_APPS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildTrustedAppsArtifact(os)); + defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); } await iterateAllListItems( @@ -208,7 +227,9 @@ export class ManifestManager { async (policyId) => { for (const os of ArtifactConstants.SUPPORTED_TRUSTED_APPS_OPERATING_SYSTEMS) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; - policySpecificArtifacts[policyId].push(await this.buildTrustedAppsArtifact(os, policyId)); + policySpecificArtifacts[policyId].push( + await this.buildArtifactsForOs({ os, policyId, ...buildArtifactsForOsOptions }) + ); } } ); @@ -224,9 +245,13 @@ export class ManifestManager { protected async buildEventFiltersArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; + const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = { + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + name: ArtifactConstants.GLOBAL_EVENT_FILTERS_NAME, + }; for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildEventFiltersForOs(os)); + defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); } await iterateAllListItems( @@ -234,7 +259,9 @@ export class ManifestManager { async (policyId) => { for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; - policySpecificArtifacts[policyId].push(await this.buildEventFiltersForOs(os, policyId)); + policySpecificArtifacts[policyId].push( + await this.buildArtifactsForOs({ os, policyId, ...buildArtifactsForOsOptions }) + ); } } ); @@ -242,21 +269,6 @@ export class ManifestManager { return { defaultArtifacts, policySpecificArtifacts }; } - protected async buildEventFiltersForOs(os: string, policyId?: string) { - return buildArtifact( - await getEndpointExceptionList({ - elClient: this.exceptionListClient, - schemaVersion: this.schemaVersion, - os, - policyId, - listId: ENDPOINT_EVENT_FILTERS_LIST_ID, - }), - this.schemaVersion, - os, - ArtifactConstants.GLOBAL_EVENT_FILTERS_NAME - ); - } - /** * Builds an array of Blocklist entries (one per supported OS) based on the current state of the * Blocklist list @@ -265,9 +277,13 @@ export class ManifestManager { protected async buildBlocklistArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; + const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = { + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + name: ArtifactConstants.GLOBAL_BLOCKLISTS_NAME, + }; for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildBlocklistForOs(os)); + defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); } await iterateAllListItems( @@ -275,7 +291,9 @@ export class ManifestManager { async (policyId) => { for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; - policySpecificArtifacts[policyId].push(await this.buildBlocklistForOs(os, policyId)); + policySpecificArtifacts[policyId].push( + await this.buildArtifactsForOs({ os, policyId, ...buildArtifactsForOsOptions }) + ); } } ); @@ -283,21 +301,6 @@ export class ManifestManager { return { defaultArtifacts, policySpecificArtifacts }; } - protected async buildBlocklistForOs(os: string, policyId?: string) { - return buildArtifact( - await getEndpointExceptionList({ - elClient: this.exceptionListClient, - schemaVersion: this.schemaVersion, - os, - policyId, - listId: ENDPOINT_BLOCKLISTS_LIST_ID, - }), - this.schemaVersion, - os, - ArtifactConstants.GLOBAL_BLOCKLISTS_NAME - ); - } - /** * Builds an array of endpoint host isolation exception (one per supported OS) based on the current state of the * Host Isolation Exception List @@ -307,9 +310,13 @@ export class ManifestManager { protected async buildHostIsolationExceptionsArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; + const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = { + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + name: ArtifactConstants.GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME, + }; for (const os of ArtifactConstants.SUPPORTED_HOST_ISOLATION_EXCEPTIONS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildHostIsolationExceptionForOs(os)); + defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); } await iterateAllListItems( @@ -318,7 +325,7 @@ export class ManifestManager { for (const os of ArtifactConstants.SUPPORTED_HOST_ISOLATION_EXCEPTIONS_OPERATING_SYSTEMS) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; policySpecificArtifacts[policyId].push( - await this.buildHostIsolationExceptionForOs(os, policyId) + await this.buildArtifactsForOs({ os, policyId, ...buildArtifactsForOsOptions }) ); } } @@ -327,24 +334,6 @@ export class ManifestManager { return { defaultArtifacts, policySpecificArtifacts }; } - protected async buildHostIsolationExceptionForOs( - os: string, - policyId?: string - ): Promise { - return buildArtifact( - await getEndpointExceptionList({ - elClient: this.exceptionListClient, - schemaVersion: this.schemaVersion, - os, - policyId, - listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, - }), - this.schemaVersion, - os, - ArtifactConstants.GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME - ); - } - /** * Writes new artifact SO. * diff --git a/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts b/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts index bcc87c3c54fae..337b851466f49 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts @@ -14,6 +14,7 @@ const FEATURES = { HOST_ISOLATION_EXCEPTION_BY_POLICY: 'Host isolation exception by policy', TRUSTED_APP_BY_POLICY: 'Trusted app by policy', EVENT_FILTERS_BY_POLICY: 'Event filters by policy', + BLOCKLIST_BY_POLICY: 'Blocklists by policy', RANSOMWARE_PROTECTION: 'Ransomeware protection', MEMORY_THREAT_PROTECTION: 'Memory threat protection', BEHAVIOR_PROTECTION: 'Behavior protection', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts index 691548c0a9efd..d5c6c0da2cec7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts @@ -73,7 +73,7 @@ export const addPrepackedRulesRoute = (router: SecuritySolutionPluginRouter) => ); }; -class PrepackagedRulesError extends Error { +export class PrepackagedRulesError extends Error { public readonly statusCode: number; constructor(message: string, statusCode: number) { super(message); @@ -147,10 +147,10 @@ export const createPrepackagedRules = async ( await updatePrepackagedRules( rulesClient, savedObjectsClient, - context.getSpaceId(), rulesToUpdate, signalsIndex, - ruleRegistryEnabled + ruleRegistryEnabled, + context.getRuleExecutionLog() ); const prepackagedRulesOutput: PrePackagedRulesAndTimelinesSchema = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index 5ff5358fbc4cd..ef9d198d2040f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -12,7 +12,7 @@ import { normalizeThresholdObject, } from '../../../../common/detection_engine/utils'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; -import { SanitizedAlert } from '../../../../../alerting/common'; +import { AlertTypeParams, SanitizedAlert } from '../../../../../alerting/common'; import { DEFAULT_INDICATOR_SOURCE_PATH, NOTIFICATION_THROTTLE_NO_ACTIONS, @@ -20,7 +20,7 @@ import { } from '../../../../common/constants'; import { CreateRulesOptions } from './types'; import { addTags } from './add_tags'; -import { PartialFilter, RuleTypeParams } from '../types'; +import { PartialFilter } from '../types'; import { transformToAlertThrottle, transformToNotifyWhen } from './utils'; export const createRules = async ({ @@ -76,8 +76,12 @@ export const createRules = async ({ exceptionsList, actions, isRuleRegistryEnabled, -}: CreateRulesOptions): Promise> => { - const rule = await rulesClient.create({ + id, +}: CreateRulesOptions): Promise> => { + const rule = await rulesClient.create({ + options: { + id, + }, data: { name, tags: addTags(tags, ruleId, immutable), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 74fb5bfe672a0..7e66f1d0aa7a2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -194,6 +194,7 @@ export interface CreateRulesOptions { actions: RuleAlertAction[]; isRuleRegistryEnabled: boolean; namespace?: NamespaceOrUndefined; + id?: string; } export interface UpdateRulesOptions { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts index 703b0a4f5aec1..44a7fa58a385f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts @@ -11,6 +11,7 @@ import { getFindResultWithSingleHit } from '../routes/__mocks__/request_response import { updatePrepackagedRules } from './update_prepacked_rules'; import { patchRules } from './patch_rules'; import { getAddPrepackagedRulesSchemaDecodedMock } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock'; +import { ruleExecutionLogMock } from '../rule_execution_log/__mocks__'; jest.mock('./patch_rules'); @@ -20,10 +21,12 @@ describe.each([ ])('updatePrepackagedRules - %s', (_, isRuleRegistryEnabled) => { let rulesClient: ReturnType; let savedObjectsClient: ReturnType; + let ruleExecutionLog: ReturnType; beforeEach(() => { rulesClient = rulesClientMock.create(); savedObjectsClient = savedObjectsClientMock.create(); + ruleExecutionLog = ruleExecutionLogMock.forRoutes.create(); }); it('should omit actions and enabled when calling patchRules', async () => { @@ -42,10 +45,10 @@ describe.each([ await updatePrepackagedRules( rulesClient, savedObjectsClient, - 'default', [{ ...prepackagedRule, actions }], outputIndex, - isRuleRegistryEnabled + isRuleRegistryEnabled, + ruleExecutionLog ); expect(patchRules).toHaveBeenCalledWith( @@ -73,10 +76,10 @@ describe.each([ await updatePrepackagedRules( rulesClient, savedObjectsClient, - 'default', [{ ...prepackagedRule, ...updatedThreatParams }], 'output-index', - isRuleRegistryEnabled + isRuleRegistryEnabled, + ruleExecutionLog ); expect(patchRules).toHaveBeenCalledWith( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index f6b4508405c5e..ceb6a3739bd6c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -15,6 +15,11 @@ import { readRules } from './read_rules'; import { PartialFilter } from '../types'; import { RuleParams } from '../schemas/rule_schemas'; import { legacyMigrate } from './utils'; +import { deleteRules } from './delete_rules'; +import { PrepackagedRulesError } from '../routes/rules/add_prepackaged_rules_route'; +import { IRuleExecutionLogForRoutes } from '../rule_execution_log'; +import { createRules } from './create_rules'; +import { transformAlertToRuleAction } from '../../../../common/detection_engine/transform_actions'; /** * Updates the prepackaged rules given a set of rules and output index. @@ -28,20 +33,20 @@ import { legacyMigrate } from './utils'; export const updatePrepackagedRules = async ( rulesClient: RulesClient, savedObjectsClient: SavedObjectsClientContract, - spaceId: string, rules: AddPrepackagedRulesSchemaDecoded[], outputIndex: string, - isRuleRegistryEnabled: boolean + isRuleRegistryEnabled: boolean, + ruleExecutionLog: IRuleExecutionLogForRoutes ): Promise => { const ruleChunks = chunk(MAX_RULES_TO_UPDATE_IN_PARALLEL, rules); for (const ruleChunk of ruleChunks) { const rulePromises = createPromises( rulesClient, savedObjectsClient, - spaceId, ruleChunk, outputIndex, - isRuleRegistryEnabled + isRuleRegistryEnabled, + ruleExecutionLog ); await Promise.all(rulePromises); } @@ -58,10 +63,10 @@ export const updatePrepackagedRules = async ( export const createPromises = ( rulesClient: RulesClient, savedObjectsClient: SavedObjectsClientContract, - spaceId: string, rules: AddPrepackagedRulesSchemaDecoded[], outputIndex: string, - isRuleRegistryEnabled: boolean + isRuleRegistryEnabled: boolean, + ruleExecutionLog: IRuleExecutionLogForRoutes ): Array | null>> => { return rules.map(async (rule) => { const { @@ -128,58 +133,130 @@ export const createPromises = ( rule: existingRule, }); - // Note: we do not pass down enabled as we do not want to suddenly disable - // or enable rules on the user when they were not expecting it if a rule updates - return patchRules({ - rulesClient, - author, - buildingBlockType, - description, - eventCategoryOverride, - falsePositives, - from, - query, - language, - license, - outputIndex, - rule: migratedRule, - savedId, - meta, - filters, - index, - interval, - maxSignals, - riskScore, - riskScoreMapping, - ruleNameOverride, - name, - severity, - severityMapping, - tags, - timestampOverride, - to, - type, - threat, - threshold, - threatFilters, - threatIndex, - threatIndicatorPath, - threatQuery, - threatMapping, - threatLanguage, - concurrentSearches, - itemsPerSearch, - references, - version, - note, - anomalyThreshold, - enabled: undefined, - timelineId, - timelineTitle, - machineLearningJobId, - exceptionsList, - throttle, - actions: undefined, - }); + if (!migratedRule) { + throw new PrepackagedRulesError(`Failed to find rule ${ruleId}`, 500); + } + + // If we're trying to change the type of a prepackaged rule, we need to delete the old one + // and replace it with the new rule, keeping the enabled setting, actions, throttle, id, + // and exception lists from the old rule + if (type !== migratedRule.params.type) { + await deleteRules({ + ruleId: migratedRule.id, + rulesClient, + ruleExecutionLog, + }); + + return (await createRules({ + id: migratedRule.id, + isRuleRegistryEnabled, + rulesClient, + anomalyThreshold, + author, + buildingBlockType, + description, + enabled: migratedRule.enabled, // Enabled comes from existing rule + eventCategoryOverride, + falsePositives, + from, + immutable: true, // At the moment we force all prepackaged rules to be immutable + query, + language, + license, + machineLearningJobId, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + ruleId, + index, + interval, + maxSignals, + riskScore, + riskScoreMapping, + ruleNameOverride, + name, + severity, + severityMapping, + tags, + to, + type, + threat, + threatFilters, + threatMapping, + threatLanguage, + concurrentSearches, + itemsPerSearch, + threatQuery, + threatIndex, + threatIndicatorPath, + threshold, + throttle: migratedRule.throttle, // Throttle comes from the existing rule + timestampOverride, + references, + note, + version, + // The exceptions list passed in to this function has already been merged with the exceptions list of + // the existing rule + exceptionsList, + actions: migratedRule.actions.map(transformAlertToRuleAction), // Actions come from the existing rule + })) as PartialAlert; // TODO: Replace AddPrepackagedRulesSchema with type specific rules schema so we can clean up these types + } else { + // Note: we do not pass down enabled as we do not want to suddenly disable + // or enable rules on the user when they were not expecting it if a rule updates + return patchRules({ + rulesClient, + author, + buildingBlockType, + description, + eventCategoryOverride, + falsePositives, + from, + query, + language, + license, + outputIndex, + rule: migratedRule, + savedId, + meta, + filters, + index, + interval, + maxSignals, + riskScore, + riskScoreMapping, + ruleNameOverride, + name, + severity, + severityMapping, + tags, + timestampOverride, + to, + type, + threat, + threshold, + threatFilters, + threatIndex, + threatIndicatorPath, + threatQuery, + threatMapping, + threatLanguage, + concurrentSearches, + itemsPerSearch, + references, + version, + note, + anomalyThreshold, + enabled: undefined, + timelineId, + timelineTitle, + machineLearningJobId, + exceptionsList, + throttle, + actions: undefined, + }); + } }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts index a96eb50af3c50..1b4baaa0607b0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts @@ -36,6 +36,7 @@ describe('build_threat_mapping_filter', () => { threatMapping, threatList, chunkSize: 1025, + entryKey: 'value', }) ).toThrow('chunk sizes cannot exceed 1024 in size'); }); @@ -44,28 +45,28 @@ describe('build_threat_mapping_filter', () => { const threatMapping = getThreatMappingMock(); const threatList = getThreatListSearchResponseMock().hits.hits; expect(() => - buildThreatMappingFilter({ threatMapping, threatList, chunkSize: 1023 }) + buildThreatMappingFilter({ threatMapping, threatList, chunkSize: 1023, entryKey: 'value' }) ).not.toThrow(); }); test('it should create the correct entries when using the default mocks', () => { const threatMapping = getThreatMappingMock(); const threatList = getThreatListSearchResponseMock().hits.hits; - const filter = buildThreatMappingFilter({ threatMapping, threatList }); + const filter = buildThreatMappingFilter({ threatMapping, threatList, entryKey: 'value' }); expect(filter).toEqual(getThreatMappingFilterMock()); }); test('it should not mutate the original threatMapping', () => { const threatMapping = getThreatMappingMock(); const threatList = getThreatListSearchResponseMock().hits.hits; - buildThreatMappingFilter({ threatMapping, threatList }); + buildThreatMappingFilter({ threatMapping, threatList, entryKey: 'value' }); expect(threatMapping).toEqual(getThreatMappingMock()); }); test('it should not mutate the original threatListItem', () => { const threatMapping = getThreatMappingMock(); const threatList = getThreatListSearchResponseMock().hits.hits; - buildThreatMappingFilter({ threatMapping, threatList }); + buildThreatMappingFilter({ threatMapping, threatList, entryKey: 'value' }); expect(threatList).toEqual(getThreatListSearchResponseMock().hits.hits); }); }); @@ -75,7 +76,7 @@ describe('build_threat_mapping_filter', () => { const threatMapping = getThreatMappingMock(); const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const item = filterThreatMapping({ threatMapping, threatListItem }); + const item = filterThreatMapping({ threatMapping, threatListItem, entryKey: 'value' }); const expected = getFilterThreatMapping(); expect(item).toEqual(expected); }); @@ -84,7 +85,11 @@ describe('build_threat_mapping_filter', () => { const [firstElement] = getThreatMappingMock(); // get only the first element const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const item = filterThreatMapping({ threatMapping: [firstElement], threatListItem }); + const item = filterThreatMapping({ + threatMapping: [firstElement], + threatListItem, + entryKey: 'value', + }); const [firstElementFilter] = getFilterThreatMapping(); // get only the first element to compare expect(item).toEqual([firstElementFilter]); }); @@ -96,6 +101,7 @@ describe('build_threat_mapping_filter', () => { filterThreatMapping({ threatMapping, threatListItem, + entryKey: 'value', }); expect(threatMapping).toEqual(getThreatMappingMock()); }); @@ -107,6 +113,7 @@ describe('build_threat_mapping_filter', () => { filterThreatMapping({ threatMapping, threatListItem, + entryKey: 'value', }); expect(threatListItem).toEqual(getThreatListSearchResponseMock().hits.hits[0]); }); @@ -142,6 +149,7 @@ describe('build_threat_mapping_filter', () => { 'host.name': ['host-1'], }, }), + entryKey: 'value', }); expect(item).toEqual([]); }); @@ -185,6 +193,7 @@ describe('build_threat_mapping_filter', () => { 'host.name': ['host-1'], }, }), + entryKey: 'value', }); expect(item).toEqual([ { @@ -204,7 +213,11 @@ describe('build_threat_mapping_filter', () => { test('it should return two clauses given a single entry', () => { const [{ entries: threatMappingEntries }] = getThreatMappingMock(); // get the first element const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries, + threatListItem, + entryKey: 'value', + }); const { bool: { should: [ @@ -219,7 +232,11 @@ describe('build_threat_mapping_filter', () => { test('it should return an empty array given an empty array', () => { const threatListItem = getThreatListItemMock(); - const innerClause = createInnerAndClauses({ threatMappingEntries: [], threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries: [], + threatListItem, + entryKey: 'value', + }); expect(innerClause).toEqual([]); }); @@ -234,7 +251,11 @@ describe('build_threat_mapping_filter', () => { }, ]; const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries, + threatListItem, + entryKey: 'value', + }); const { bool: { should: [ @@ -263,7 +284,11 @@ describe('build_threat_mapping_filter', () => { }, ]; const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries, + threatListItem, + entryKey: 'value', + }); const { bool: { should: [ @@ -290,7 +315,11 @@ describe('build_threat_mapping_filter', () => { }, ]; const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries, + threatListItem, + entryKey: 'value', + }); expect(innerClause).toEqual([]); }); }); @@ -299,7 +328,7 @@ describe('build_threat_mapping_filter', () => { test('it should return all clauses given the entries', () => { const threatMapping = getThreatMappingMock(); const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createAndOrClauses({ threatMapping, threatListItem }); + const innerClause = createAndOrClauses({ threatMapping, threatListItem, entryKey: 'value' }); expect(innerClause).toEqual(getThreatMappingFilterShouldMock()); }); @@ -310,13 +339,17 @@ describe('build_threat_mapping_filter', () => { ...getThreatListSearchResponseMock().hits.hits[0]._source, foo: 'bar', }; - const innerClause = createAndOrClauses({ threatMapping, threatListItem }); + const innerClause = createAndOrClauses({ threatMapping, threatListItem, entryKey: 'value' }); expect(innerClause).toEqual(getThreatMappingFilterShouldMock()); }); test('it should return an empty boolean given an empty array', () => { const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createAndOrClauses({ threatMapping: [], threatListItem }); + const innerClause = createAndOrClauses({ + threatMapping: [], + threatListItem, + entryKey: 'value', + }); expect(innerClause).toEqual({ bool: { minimum_should_match: 1, should: [] } }); }); @@ -325,6 +358,7 @@ describe('build_threat_mapping_filter', () => { const innerClause = createAndOrClauses({ threatMapping, threatListItem: getThreatListItemMock({ _source: {}, fields: {} }), + entryKey: 'value', }); expect(innerClause).toEqual({ bool: { minimum_should_match: 1, should: [] } }); }); @@ -338,6 +372,7 @@ describe('build_threat_mapping_filter', () => { threatMapping, threatList, chunkSize: 1024, + entryKey: 'value', }); const expected: BooleanFilter = { bool: { should: [getThreatMappingFilterShouldMock()], minimum_should_match: 1 }, @@ -352,6 +387,7 @@ describe('build_threat_mapping_filter', () => { threatMapping, threatList, chunkSize: 1024, + entryKey: 'value', }); const expected: BooleanFilter = { bool: { should: [], minimum_should_match: 1 }, @@ -365,6 +401,7 @@ describe('build_threat_mapping_filter', () => { threatMapping: [], threatList, chunkSize: 1024, + entryKey: 'value', }); const expected: BooleanFilter = { bool: { should: [], minimum_should_match: 1 }, @@ -399,6 +436,7 @@ describe('build_threat_mapping_filter', () => { threatMapping, threatList, chunkSize: 1024, + entryKey: 'value', }); const expected: BooleanFilter = { bool: { should: [getThreatMappingFilterShouldMock()], minimum_should_match: 1 }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts index dfc66f7c5222e..82b6c5a6c523f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts @@ -25,6 +25,7 @@ export const buildThreatMappingFilter = ({ threatMapping, threatList, chunkSize, + entryKey = 'value', }: BuildThreatMappingFilterOptions): Filter => { const computedChunkSize = chunkSize ?? MAX_CHUNK_SIZE; if (computedChunkSize > 1024) { @@ -34,6 +35,7 @@ export const buildThreatMappingFilter = ({ threatMapping, threatList, chunkSize: computedChunkSize, + entryKey, }); const filterChunk: Filter = { meta: { @@ -52,11 +54,12 @@ export const buildThreatMappingFilter = ({ export const filterThreatMapping = ({ threatMapping, threatListItem, + entryKey, }: FilterThreatMappingOptions): ThreatMapping => threatMapping .map((threatMap) => { const atLeastOneItemMissingInThreatList = threatMap.entries.some((entry) => { - const itemValue = get(entry.value, threatListItem.fields); + const itemValue = get(entry[entryKey], threatListItem.fields); return itemValue == null || itemValue.length !== 1; }); if (atLeastOneItemMissingInThreatList) { @@ -70,9 +73,10 @@ export const filterThreatMapping = ({ export const createInnerAndClauses = ({ threatMappingEntries, threatListItem, + entryKey, }: CreateInnerAndClausesOptions): BooleanFilter[] => { return threatMappingEntries.reduce((accum, threatMappingEntry) => { - const value = get(threatMappingEntry.value, threatListItem.fields); + const value = get(threatMappingEntry[entryKey], threatListItem.fields); if (value != null && value.length === 1) { // These values could be potentially 10k+ large so mutating the array intentionally accum.push({ @@ -80,7 +84,7 @@ export const createInnerAndClauses = ({ should: [ { match: { - [threatMappingEntry.field]: { + [threatMappingEntry[entryKey === 'field' ? 'value' : 'field']]: { query: value[0], _name: encodeThreatMatchNamedQuery({ id: threatListItem._id, @@ -103,11 +107,13 @@ export const createInnerAndClauses = ({ export const createAndOrClauses = ({ threatMapping, threatListItem, + entryKey, }: CreateAndOrClausesOptions): BooleanFilter => { const should = threatMapping.reduce((accum, threatMap) => { const innerAndClauses = createInnerAndClauses({ threatMappingEntries: threatMap.entries, threatListItem, + entryKey, }); if (innerAndClauses.length !== 0) { // These values could be potentially 10k+ large so mutating the array intentionally @@ -124,15 +130,18 @@ export const buildEntriesMappingFilter = ({ threatMapping, threatList, chunkSize, + entryKey, }: BuildEntriesMappingFilterOptions): BooleanFilter => { const combinedShould = threatList.reduce((accum, threatListSearchItem) => { const filteredEntries = filterThreatMapping({ threatMapping, threatListItem: threatListSearchItem, + entryKey, }); const queryWithAndOrClause = createAndOrClauses({ threatMapping: filteredEntries, threatListItem: threatListSearchItem, + entryKey, }); if (queryWithAndOrClause.bool.should.length !== 0) { // These values can be 10k+ large, so using a push here for performance diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts new file mode 100644 index 0000000000000..c1beb55e90a85 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildThreatMappingFilter } from './build_threat_mapping_filter'; +import { getFilter } from '../get_filter'; +import { searchAfterAndBulkCreate } from '../search_after_bulk_create'; +import { buildReasonMessageForThreatMatchAlert } from '../reason_formatters'; +import { CreateEventSignalOptions } from './types'; +import { SearchAfterAndBulkCreateReturnType, SignalSearchResponse } from '../types'; +import { getAllThreatListHits } from './get_threat_list'; +import { + enrichSignalThreatMatches, + getSignalMatchesFromThreatList, +} from './enrich_signal_threat_matches'; + +export const createEventSignal = async ({ + alertId, + buildRuleMessage, + bulkCreate, + completeRule, + currentResult, + currentEventList, + eventsTelemetry, + exceptionItems, + filters, + inputIndex, + language, + listClient, + logger, + outputIndex, + query, + savedId, + searchAfterSize, + services, + threatMapping, + tuple, + type, + wrapHits, + threatQuery, + threatFilters, + threatLanguage, + threatIndex, + threatListConfig, + threatIndicatorPath, + perPage, +}: CreateEventSignalOptions): Promise => { + const threatFilter = buildThreatMappingFilter({ + threatMapping, + threatList: currentEventList, + entryKey: 'field', + }); + + if (!threatFilter.query || threatFilter.query?.bool.should.length === 0) { + // empty event list and we do not want to return everything as being + // a hit so opt to return the existing result. + logger.debug( + buildRuleMessage( + 'Indicator items are empty after filtering for missing data, returning without attempting a match' + ) + ); + return currentResult; + } else { + const threatListHits = await getAllThreatListHits({ + esClient: services.scopedClusterClient.asCurrentUser, + exceptionItems, + threatFilters: [...threatFilters, threatFilter], + query: threatQuery, + language: threatLanguage, + index: threatIndex, + logger, + buildRuleMessage, + threatListConfig: { + _source: [`${threatIndicatorPath}.*`, 'threat.feed.*'], + fields: undefined, + }, + perPage, + }); + + const signalMatches = getSignalMatchesFromThreatList(threatListHits); + + const ids = signalMatches.map((item) => item.signalId); + + const indexFilter = { + query: { + bool: { + filter: { + ids: { values: ids }, + }, + }, + }, + }; + + const esFilter = await getFilter({ + type, + filters: [...filters, indexFilter], + language, + query, + savedId, + services, + index: inputIndex, + lists: exceptionItems, + }); + + logger.debug( + buildRuleMessage( + `${ids?.length} matched signals found from ${threatListHits.length} indicators` + ) + ); + + const threatEnrichment = (signals: SignalSearchResponse): Promise => + enrichSignalThreatMatches( + signals, + () => Promise.resolve(threatListHits), + threatIndicatorPath, + signalMatches + ); + + const result = await searchAfterAndBulkCreate({ + buildReasonMessage: buildReasonMessageForThreatMatchAlert, + buildRuleMessage, + bulkCreate, + completeRule, + enrichment: threatEnrichment, + eventsTelemetry, + exceptionsList: exceptionItems, + filter: esFilter, + id: alertId, + inputIndexPattern: inputIndex, + listClient, + logger, + pageSize: searchAfterSize, + services, + signalsIndex: outputIndex, + sortOrder: 'desc', + trackTotalHits: false, + tuple, + wrapHits, + }); + + logger.debug( + buildRuleMessage( + `${ + threatFilter.query?.bool.should.length + } items have completed match checks and the total times to search were ${ + result.searchAfterTimes.length !== 0 ? result.searchAfterTimes : '(unknown) ' + }ms` + ) + ); + return result; + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index bf72a13ba0450..220bebbaa4d21 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -41,6 +41,7 @@ export const createThreatSignal = async ({ const threatFilter = buildThreatMappingFilter({ threatMapping, threatList: currentThreatList, + entryKey: 'value', }); if (!threatFilter.query || threatFilter.query?.bool.should.length === 0) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index 292a5f897885f..eecc55a67ad52 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -7,13 +7,17 @@ import chunk from 'lodash/fp/chunk'; import { getThreatList, getThreatListCount } from './get_threat_list'; - -import { CreateThreatSignalsOptions } from './types'; +import { + CreateThreatSignalsOptions, + CreateSignalInterface, + GetDocumentListInterface, +} from './types'; import { createThreatSignal } from './create_threat_signal'; +import { createEventSignal } from './create_event_signal'; import { SearchAfterAndBulkCreateReturnType } from '../types'; import { buildExecutionIntervalValidator, combineConcurrentResults } from './utils'; import { buildThreatEnrichment } from './build_threat_enrichment'; -import { getEventCount } from './get_event_count'; +import { getEventCount, getEventList } from './get_event_count'; import { getMappingFilters } from './get_mapping_filters'; export const createThreatSignals = async ({ @@ -85,7 +89,7 @@ export const createThreatSignals = async ({ return results; } - let threatListCount = await getThreatListCount({ + const threatListCount = await getThreatListCount({ esClient: services.scopedClusterClient.asCurrentUser, exceptionItems, threatFilters: allThreatFilters, @@ -101,20 +105,6 @@ export const createThreatSignals = async ({ _source: false, }; - let threatList = await getThreatList({ - esClient: services.scopedClusterClient.asCurrentUser, - exceptionItems, - threatFilters: allThreatFilters, - query: threatQuery, - language: threatLanguage, - index: threatIndex, - searchAfter: undefined, - logger, - buildRuleMessage, - perPage, - threatListConfig, - }); - const threatEnrichment = buildThreatEnrichment({ buildRuleMessage, exceptionItems, @@ -127,12 +117,124 @@ export const createThreatSignals = async ({ threatQuery, }); - while (threatList.hits.hits.length !== 0) { - verifyExecutionCanProceed(); - const chunks = chunk(itemsPerSearch, threatList.hits.hits); - logger.debug(buildRuleMessage(`${chunks.length} concurrent indicator searches are starting.`)); - const concurrentSearchesPerformed = chunks.map>( - (slicedChunk) => + const createSignals = async ({ + getDocumentList, + createSignal, + totalDocumentCount, + }: { + getDocumentList: GetDocumentListInterface; + createSignal: CreateSignalInterface; + totalDocumentCount: number; + }) => { + let list = await getDocumentList({ searchAfter: undefined }); + let documentCount = totalDocumentCount; + + while (list.hits.hits.length !== 0) { + verifyExecutionCanProceed(); + const chunks = chunk(itemsPerSearch, list.hits.hits); + logger.debug( + buildRuleMessage(`${chunks.length} concurrent indicator searches are starting.`) + ); + const concurrentSearchesPerformed = + chunks.map>(createSignal); + const searchesPerformed = await Promise.all(concurrentSearchesPerformed); + results = combineConcurrentResults(results, searchesPerformed); + documentCount -= list.hits.hits.length; + logger.debug( + buildRuleMessage( + `Concurrent indicator match searches completed with ${results.createdSignalsCount} signals found`, + `search times of ${results.searchAfterTimes}ms,`, + `bulk create times ${results.bulkCreateTimes}ms,`, + `all successes are ${results.success}` + ) + ); + if (results.createdSignalsCount >= params.maxSignals) { + logger.debug( + buildRuleMessage( + `Indicator match has reached its max signals count ${params.maxSignals}. Additional documents not checked are ${documentCount}` + ) + ); + break; + } + logger.debug(buildRuleMessage(`Documents items left to check are ${documentCount}`)); + + list = await getDocumentList({ + searchAfter: list.hits.hits[list.hits.hits.length - 1].sort, + }); + } + }; + + if (eventCount < threatListCount) { + await createSignals({ + totalDocumentCount: eventCount, + getDocumentList: async ({ searchAfter }) => + getEventList({ + services, + exceptionItems, + filters: allEventFilters, + query, + language, + index: inputIndex, + searchAfter, + logger, + buildRuleMessage, + perPage, + tuple, + }), + + createSignal: (slicedChunk) => + createEventSignal({ + alertId, + buildRuleMessage, + bulkCreate, + completeRule, + currentResult: results, + currentEventList: slicedChunk, + eventsTelemetry, + exceptionItems, + filters: allEventFilters, + inputIndex, + language, + listClient, + logger, + outputIndex, + query, + savedId, + searchAfterSize, + services, + threatEnrichment, + threatMapping, + tuple, + type, + wrapHits, + threatQuery, + threatFilters: allThreatFilters, + threatLanguage, + threatIndex, + threatListConfig, + threatIndicatorPath, + perPage, + }), + }); + } else { + await createSignals({ + totalDocumentCount: threatListCount, + getDocumentList: async ({ searchAfter }) => + getThreatList({ + esClient: services.scopedClusterClient.asCurrentUser, + exceptionItems, + threatFilters: allThreatFilters, + query: threatQuery, + language: threatLanguage, + index: threatIndex, + searchAfter, + logger, + buildRuleMessage, + perPage, + threatListConfig, + }), + + createSignal: (slicedChunk) => createThreatSignal({ alertId, buildRuleMessage, @@ -157,41 +259,7 @@ export const createThreatSignals = async ({ tuple, type, wrapHits, - }) - ); - const searchesPerformed = await Promise.all(concurrentSearchesPerformed); - results = combineConcurrentResults(results, searchesPerformed); - threatListCount -= threatList.hits.hits.length; - logger.debug( - buildRuleMessage( - `Concurrent indicator match searches completed with ${results.createdSignalsCount} signals found`, - `search times of ${results.searchAfterTimes}ms,`, - `bulk create times ${results.bulkCreateTimes}ms,`, - `all successes are ${results.success}` - ) - ); - if (results.createdSignalsCount >= params.maxSignals) { - logger.debug( - buildRuleMessage( - `Indicator match has reached its max signals count ${params.maxSignals}. Additional indicator items not checked are ${threatListCount}` - ) - ); - break; - } - logger.debug(buildRuleMessage(`Indicator items left to check are ${threatListCount}`)); - - threatList = await getThreatList({ - esClient: services.scopedClusterClient.asCurrentUser, - exceptionItems, - query: threatQuery, - language: threatLanguage, - threatFilters: allThreatFilters, - index: threatIndex, - searchAfter: threatList.hits.hits[threatList.hits.hits.length - 1].sort, - buildRuleMessage, - logger, - perPage, - threatListConfig, + }), }); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts index 4e249711bb890..66e44e5796eb6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts @@ -14,6 +14,7 @@ import { buildEnrichments, enrichSignalThreatMatches, groupAndMergeSignalMatches, + getSignalMatchesFromThreatList, } from './enrich_signal_threat_matches'; import { getNamedQueryMock, @@ -793,3 +794,107 @@ describe('enrichSignalThreatMatches', () => { ]); }); }); + +describe('getSignalMatchesFromThreatList', () => { + it('return empty array if there no threat indicators', () => { + const signalMatches = getSignalMatchesFromThreatList(); + expect(signalMatches).toEqual([]); + }); + + it("return empty array if threat indicators doesn't have matched query", () => { + const signalMatches = getSignalMatchesFromThreatList([getThreatListItemMock()]); + expect(signalMatches).toEqual([]); + }); + + it('return signal mathces from threat indicators', () => { + const signalMatches = getSignalMatchesFromThreatList([ + getThreatListItemMock({ + _id: 'threatId', + matched_queries: [ + encodeThreatMatchNamedQuery( + getNamedQueryMock({ + id: 'signalId1', + index: 'source_index', + value: 'threat.indicator.domain', + field: 'event.domain', + }) + ), + encodeThreatMatchNamedQuery( + getNamedQueryMock({ + id: 'signalId2', + index: 'source_index', + value: 'threat.indicator.domain', + field: 'event.domain', + }) + ), + ], + }), + ]); + + const queries = [ + { + field: 'event.domain', + value: 'threat.indicator.domain', + index: 'threat_index', + id: 'threatId', + }, + ]; + + expect(signalMatches).toEqual([ + { + signalId: 'signalId1', + queries, + }, + { + signalId: 'signalId2', + queries, + }, + ]); + }); + + it('merge signal mathces if different threat indicators matched the same signal', () => { + const matchedQuery = [ + encodeThreatMatchNamedQuery( + getNamedQueryMock({ + id: 'signalId', + index: 'source_index', + value: 'threat.indicator.domain', + field: 'event.domain', + }) + ), + ]; + const signalMatches = getSignalMatchesFromThreatList([ + getThreatListItemMock({ + _id: 'threatId1', + matched_queries: matchedQuery, + }), + getThreatListItemMock({ + _id: 'threatId2', + matched_queries: matchedQuery, + }), + ]); + + const query = { + field: 'event.domain', + value: 'threat.indicator.domain', + index: 'threat_index', + id: 'threatId', + }; + + expect(signalMatches).toEqual([ + { + signalId: 'signalId', + queries: [ + { + ...query, + id: 'threatId1', + }, + { + ...query, + id: 'threatId2', + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts index 8c7b0b89a0cb7..c1fb88176fd4c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts @@ -14,9 +14,43 @@ import type { ThreatEnrichment, ThreatListItem, ThreatMatchNamedQuery, + SignalMatch, } from './types'; import { extractNamedQueries } from './utils'; +export const getSignalMatchesFromThreatList = ( + threatList: ThreatListItem[] = [] +): SignalMatch[] => { + const signalMap: { [key: string]: ThreatMatchNamedQuery[] } = {}; + + threatList.forEach((threatHit) => + extractNamedQueries(threatHit).forEach((item) => { + const signalId = item.id; + if (!signalId) { + return; + } + + if (!signalMap[signalId]) { + signalMap[signalId] = []; + } + + signalMap[signalId].push({ + id: threatHit._id, + index: threatHit._index, + field: item.field, + value: item.value, + }); + }) + ); + + const signalMatches = Object.entries(signalMap).map(([key, value]) => ({ + signalId: key, + queries: value, + })); + + return signalMatches; +}; + const getSignalId = (signal: SignalSourceHit): string => signal._id; export const groupAndMergeSignalMatches = (signalHits: SignalSourceHit[]): SignalSourceHit[] => { @@ -77,7 +111,8 @@ export const buildEnrichments = ({ export const enrichSignalThreatMatches = async ( signals: SignalSearchResponse, getMatchedThreats: GetMatchedThreats, - indicatorPath: string + indicatorPath: string, + signalMatchesArg?: SignalMatch[] ): Promise => { const signalHits = signals.hits.hits; if (signalHits.length === 0) { @@ -85,13 +120,27 @@ export const enrichSignalThreatMatches = async ( } const uniqueHits = groupAndMergeSignalMatches(signalHits); - const signalMatches = uniqueHits.map((signalHit) => extractNamedQueries(signalHit)); - const matchedThreatIds = [...new Set(signalMatches.flat().map(({ id }) => id))]; + const signalMatches: SignalMatch[] = signalMatchesArg + ? signalMatchesArg + : uniqueHits.map((signalHit) => ({ + signalId: signalHit._id, + queries: extractNamedQueries(signalHit), + })); + + const matchedThreatIds = [ + ...new Set( + signalMatches + .map((signalMatch) => signalMatch.queries) + .flat() + .map(({ id }) => id) + ), + ]; const matchedThreats = await getMatchedThreats(matchedThreatIds); - const enrichmentsWithoutAtomic = signalMatches.map((queries) => + + const enrichmentsWithoutAtomic = signalMatches.map((signalMatch) => buildEnrichments({ indicatorPath, - queries, + queries: signalMatch.queries, threats: matchedThreats, }) ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.ts index 28a994280abed..2c6d3bd8cc38d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.ts @@ -5,10 +5,62 @@ * 2.0. */ -import { EventCountOptions } from './types'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { EventCountOptions, EventsOptions, EventDoc } from './types'; import { getQueryFilter } from '../../../../../common/detection_engine/get_query_filter'; +import { singleSearchAfter } from '../../signals/single_search_after'; import { buildEventsSearchQuery } from '../build_events_query'; +export const MAX_PER_PAGE = 9000; + +export const getEventList = async ({ + services, + query, + language, + index, + perPage, + searchAfter, + exceptionItems, + filters, + buildRuleMessage, + logger, + tuple, + timestampOverride, +}: EventsOptions): Promise> => { + const calculatedPerPage = perPage ?? MAX_PER_PAGE; + if (calculatedPerPage > 10000) { + throw new TypeError('perPage cannot exceed the size of 10000'); + } + + logger.debug( + buildRuleMessage( + `Querying the events items from the index: "${index}" with searchAfter: "${searchAfter}" for up to ${calculatedPerPage} indicator items` + ) + ); + + const filter = getQueryFilter(query, language ?? 'kuery', filters, index, exceptionItems); + + const { searchResult } = await singleSearchAfter({ + buildRuleMessage, + searchAfterSortIds: searchAfter, + index, + from: tuple.from.toISOString(), + to: tuple.to.toISOString(), + services, + logger, + filter, + pageSize: Math.ceil(Math.min(tuple.maxSignals, calculatedPerPage)), + timestampOverride, + sortOrder: 'desc', + trackTotalHits: false, + }); + + logger.debug( + buildRuleMessage(`Retrieved events items of size: ${searchResult.hits.hits.length}`) + ); + return searchResult; +}; + export const getEventCount = async ({ esClient, query, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts index f31c1fbfdaec3..9f2fcef2f6883 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts @@ -7,7 +7,12 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { getQueryFilter } from '../../../../../common/detection_engine/get_query_filter'; -import { GetThreatListOptions, ThreatListCountOptions, ThreatListDoc } from './types'; +import { + GetThreatListOptions, + ThreatListCountOptions, + ThreatListDoc, + ThreatListItem, +} from './types'; /** * This should not exceed 10000 (10k) @@ -89,3 +94,22 @@ export const getThreatListCount = async ({ }); return response.count; }; + +export const getAllThreatListHits = async ( + params: Omit +): Promise => { + let allThreatListHits: ThreatListItem[] = []; + let threatList = await getThreatList({ ...params, searchAfter: undefined }); + + allThreatListHits = allThreatListHits.concat(threatList.hits.hits); + + while (threatList.hits.hits.length !== 0) { + threatList = await getThreatList({ + ...params, + searchAfter: threatList.hits.hits[threatList.hits.hits.length - 1].sort, + }); + + allThreatListHits = allThreatListHits.concat(threatList.hits.hits); + } + return allThreatListHits; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index 45fa47288a958..8beabe072c13f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -94,31 +94,70 @@ export interface CreateThreatSignalOptions { wrapHits: WrapHits; } +export interface CreateEventSignalOptions { + alertId: string; + buildRuleMessage: BuildRuleMessage; + bulkCreate: BulkCreate; + completeRule: CompleteRule; + currentResult: SearchAfterAndBulkCreateReturnType; + currentEventList: EventItem[]; + eventsTelemetry: ITelemetryEventsSender | undefined; + exceptionItems: ExceptionListItemSchema[]; + filters: unknown[]; + inputIndex: string[]; + language: LanguageOrUndefined; + listClient: ListClient; + logger: Logger; + outputIndex: string; + query: string; + savedId: string | undefined; + searchAfterSize: number; + services: AlertServices; + threatEnrichment: SignalsEnrichment; + tuple: RuleRangeTuple; + type: Type; + wrapHits: WrapHits; + threatFilters: unknown[]; + threatIndex: ThreatIndex; + threatIndicatorPath: ThreatIndicatorPath; + threatLanguage: ThreatLanguageOrUndefined; + threatMapping: ThreatMapping; + threatQuery: ThreatQuery; + threatListConfig: ThreatListConfig; + perPage?: number; +} + +type EntryKey = 'field' | 'value'; export interface BuildThreatMappingFilterOptions { chunkSize?: number; threatList: ThreatListItem[]; threatMapping: ThreatMapping; + entryKey: EntryKey; } export interface FilterThreatMappingOptions { threatListItem: ThreatListItem; threatMapping: ThreatMapping; + entryKey: EntryKey; } export interface CreateInnerAndClausesOptions { threatListItem: ThreatListItem; threatMappingEntries: ThreatMappingEntries; + entryKey: EntryKey; } export interface CreateAndOrClausesOptions { threatListItem: ThreatListItem; threatMapping: ThreatMapping; + entryKey: EntryKey; } export interface BuildEntriesMappingFilterOptions { chunkSize: number; threatList: ThreatListItem[]; threatMapping: ThreatMapping; + entryKey: EntryKey; } export interface SplitShouldClausesOptions { @@ -199,6 +238,26 @@ export interface BuildThreatEnrichmentOptions { threatQuery: ThreatQuery; } +export interface EventsOptions { + services: AlertServices; + query: string; + buildRuleMessage: BuildRuleMessage; + language: ThreatLanguageOrUndefined; + exceptionItems: ExceptionListItemSchema[]; + index: string[]; + searchAfter: estypes.SortResults | undefined; + perPage?: number; + logger: Logger; + filters: unknown[]; + timestampOverride?: string; + tuple: RuleRangeTuple; +} + +export interface EventDoc { + [key: string]: unknown; +} + +export type EventItem = estypes.SearchHit; export interface EventCountOptions { esClient: ElasticsearchClient; exceptionItems: ExceptionListItemSchema[]; @@ -209,3 +268,16 @@ export interface EventCountOptions { tuple: RuleRangeTuple; timestampOverride?: string; } + +export interface SignalMatch { + signalId: string; + queries: ThreatMatchNamedQuery[]; +} + +export type GetDocumentListInterface = (params: { + searchAfter: estypes.SortResults | undefined; +}) => Promise>; + +export type CreateSignalInterface = ( + params: EventItem[] | ThreatListItem[] +) => Promise; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts index 99f6609faec91..2918bffec3631 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts @@ -9,7 +9,7 @@ import moment from 'moment'; import { SearchAfterAndBulkCreateReturnType, SignalSourceHit } from '../types'; import { parseInterval } from '../utils'; -import { ThreatMatchNamedQuery } from './types'; +import { ThreatMatchNamedQuery, ThreatListItem } from './types'; /** * Given two timers this will take the max of each and add them to each other and return that addition. @@ -147,7 +147,9 @@ export const decodeThreatMatchNamedQuery = (encoded: string): ThreatMatchNamedQu return query; }; -export const extractNamedQueries = (hit: SignalSourceHit): ThreatMatchNamedQuery[] => +export const extractNamedQueries = ( + hit: SignalSourceHit | ThreatListItem +): ThreatMatchNamedQuery[] => hit.matched_queries?.map((match) => decodeThreatMatchNamedQuery(match)) ?? []; export const buildExecutionIntervalValidator: (interval: string) => () => void = (interval) => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts index 206ccb3b78351..f25c23d2d5ed3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts @@ -80,6 +80,7 @@ export interface RuleTypeParams extends AlertTypeParams { query?: QueryOrUndefined; filters?: unknown[]; maxSignals: MaxSignals; + namespace?: string; riskScore: RiskScore; riskScoreMapping: RiskScoreMappingOrUndefined; ruleNameOverride: RuleNameOverrideOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts index 3b55d4a789fc0..15f7b0a2a54c8 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts @@ -62,6 +62,8 @@ const allowlistBaseEventFields: AllowlistFields = { directory: true, hash: true, Ext: { + compressed_bytes: true, + compressed_bytes_present: true, code_signature: true, header_bytes: true, header_data: true, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts index d055f3843d479..dff3676c20c8a 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts @@ -59,6 +59,8 @@ describe('TelemetryEventsSender', () => { test: 'me', another: 'nope', Ext: { + compressed_bytes: 'data up to 4mb', + compressed_bytes_present: 'data up to 4mb', code_signature: { key1: 'X', key2: 'Y', @@ -131,6 +133,8 @@ describe('TelemetryEventsSender', () => { created: 0, path: 'X', Ext: { + compressed_bytes: 'data up to 4mb', + compressed_bytes_present: 'data up to 4mb', code_signature: { key1: 'X', key2: 'Y', diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts index b5bc4622423e2..add586c6cb67f 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts @@ -14,6 +14,7 @@ import { EventFilterValidator, TrustedAppValidator, HostIsolationExceptionsValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreCreateItemServerExtension['callback']; @@ -56,6 +57,14 @@ export const getExceptionsPreCreateItemHandler = ( return validatedItem; } + // Validate blocklists + if (BlocklistValidator.isBlocklist(data)) { + const blocklistValidator = new BlocklistValidator(endpointAppContext, request); + const validatedItem = await blocklistValidator.validatePreCreateItem(data); + blocklistValidator.notifyFeatureUsage(data, 'BLOCKLIST_BY_POLICY'); + return validatedItem; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts index 37d2e9e774c6a..095e4b5631540 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts @@ -12,6 +12,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreDeleteItemServerExtension['callback']; @@ -57,6 +58,12 @@ export const getExceptionsPreDeleteItemHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreDeleteItem(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts index 5750080d930e4..8067356532a3a 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts @@ -11,6 +11,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreExportServerExtension['callback']; @@ -38,6 +39,7 @@ export const getExceptionsPreExportHandler = ( await new TrustedAppValidator(endpointAppContextService, request).validatePreExport(); return data; } + // Host Isolation Exceptions validations if (HostIsolationExceptionsValidator.isHostIsolationException({ listId })) { await new HostIsolationExceptionsValidator( @@ -53,6 +55,12 @@ export const getExceptionsPreExportHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreExport(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts index 31aeb330095fe..a21a99eea3a9d 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts @@ -12,6 +12,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreGetOneItemServerExtension['callback']; @@ -57,6 +58,12 @@ export const getExceptionsPreGetOneHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreGetOneItem(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts index 323507dfb2b85..5cfe7311eb9e3 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts @@ -11,6 +11,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreMultiListFindServerExtension['callback']; @@ -21,6 +22,7 @@ export const getExceptionsPreMultiListFindHandler = ( if (!data.namespaceType.includes('agnostic')) { return data; } + // validate Trusted application if (data.listId.some((id) => TrustedAppValidator.isTrustedApp({ listId: id }))) { await new TrustedAppValidator(endpointAppContextService, request).validatePreMultiListFind(); @@ -46,6 +48,12 @@ export const getExceptionsPreMultiListFindHandler = ( return data; } + // validate Blocklist + if (data.listId.some((id) => BlocklistValidator.isBlocklist({ listId: id }))) { + await new BlocklistValidator(endpointAppContextService, request).validatePreMultiListFind(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts index c33ae013b2099..917e6c97b1bfd 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts @@ -11,6 +11,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreSingleListFindServerExtension['callback']; @@ -24,7 +25,7 @@ export const getExceptionsPreSingleListFindHandler = ( const { listId } = data; - // Validate Host Isolation Exceptions + // Validate Trusted applications if (TrustedAppValidator.isTrustedApp({ listId })) { await new TrustedAppValidator(endpointAppContextService, request).validatePreSingleListFind(); return data; @@ -48,6 +49,12 @@ export const getExceptionsPreSingleListFindHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreSingleListFind(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts index c250979058962..93c1abdcb7d7a 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts @@ -11,6 +11,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreSummaryServerExtension['callback']; @@ -38,6 +39,7 @@ export const getExceptionsPreSummaryHandler = ( await new TrustedAppValidator(endpointAppContextService, request).validatePreGetListSummary(); return data; } + // Host Isolation Exceptions if (HostIsolationExceptionsValidator.isHostIsolationException({ listId })) { await new HostIsolationExceptionsValidator( @@ -53,6 +55,12 @@ export const getExceptionsPreSummaryHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreGetListSummary(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts index 67b2e5cc03efe..acedbf7d1ed25 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts @@ -15,6 +15,7 @@ import { EventFilterValidator, TrustedAppValidator, HostIsolationExceptionsValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreUpdateItemServerExtension['callback']; @@ -86,6 +87,17 @@ export const getExceptionsPreUpdateItemHandler = ( return validatedItem; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + const blocklistValidator = new BlocklistValidator(endpointAppContextService, request); + const validatedItem = await blocklistValidator.validatePreUpdateItem(data, currentSavedItem); + blocklistValidator.notifyFeatureUsage( + data as ExceptionItemLikeOptions, + 'BLOCKLIST_BY_POLICY' + ); + return validatedItem; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts new file mode 100644 index 0000000000000..e51190467aee4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts @@ -0,0 +1,279 @@ +/* + * 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 { ENDPOINT_BLOCKLISTS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { schema, Type, TypeOf } from '@kbn/config-schema'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { BaseValidator } from './base_validator'; +import { ExceptionItemLikeOptions } from '../types'; +import { + CreateExceptionListItemOptions, + UpdateExceptionListItemOptions, +} from '../../../../../lists/server'; +import { isValidHash } from '../../../../common/endpoint/service/trusted_apps/validations'; +import { EndpointArtifactExceptionValidationError } from './errors'; + +const allowedHashes: Readonly = ['file.hash.md5', 'file.hash.sha1', 'file.hash.sha256']; + +const FileHashField = schema.oneOf( + allowedHashes.map((hash) => schema.literal(hash)) as [Type] +); + +const FilePath = schema.literal('file.path'); +const FileCodeSigner = schema.literal('file.Ext.code_signature'); + +const ConditionEntryTypeSchema = schema.literal('match_any'); +const ConditionEntryOperatorSchema = schema.literal('included'); + +type ConditionEntryFieldAllowedType = + | TypeOf + | TypeOf + | TypeOf; + +type BlocklistConditionEntry = + | { + field: ConditionEntryFieldAllowedType; + type: 'match_any'; + operator: 'included'; + value: string[]; + } + | TypeOf; + +/* + * A generic Entry schema to be used for a specific entry schema depending on the OS + */ +const CommonEntrySchema = { + field: schema.oneOf([FileHashField, FilePath]), + type: ConditionEntryTypeSchema, + operator: ConditionEntryOperatorSchema, + // If field === HASH then validate hash with custom method, else validate string with minLength = 1 + value: schema.conditional( + schema.siblingRef('field'), + FileHashField, + schema.arrayOf( + schema.string({ + validate: (hash: string) => + isValidHash(hash) ? undefined : `invalid hash value [${hash}]`, + }), + { minSize: 1 } + ), + schema.conditional( + schema.siblingRef('field'), + FilePath, + schema.arrayOf( + schema.string({ + validate: (pathValue: string) => + pathValue.length > 0 ? undefined : `invalid path value [${pathValue}]`, + }), + { minSize: 1 } + ), + schema.arrayOf( + schema.string({ + validate: (signerValue: string) => + signerValue.length > 0 ? undefined : `invalid signer value [${signerValue}]`, + }), + { minSize: 1 } + ) + ) + ), +}; + +// Windows Signer entries use a Nested field that checks to ensure +// that the certificate is trusted +const WindowsSignerEntrySchema = schema.object({ + type: schema.literal('nested'), + field: FileCodeSigner, + entries: schema.arrayOf( + schema.object({ + field: schema.literal('subject_name'), + value: schema.arrayOf(schema.string({ minLength: 1 })), + type: schema.literal('match_any'), + operator: schema.literal('included'), + }), + { minSize: 1 } + ), +}); + +const WindowsEntrySchema = schema.oneOf([ + WindowsSignerEntrySchema, + schema.object({ + ...CommonEntrySchema, + field: schema.oneOf([FileHashField, FilePath]), + }), +]); + +const LinuxEntrySchema = schema.object({ + ...CommonEntrySchema, +}); + +const MacEntrySchema = schema.object({ + ...CommonEntrySchema, +}); + +// Hash entries validator method. +const hashEntriesValidation = (entries: BlocklistConditionEntry[]) => { + const currentHashes = entries.map((entry) => entry.field); + // If there are more hashes than allowed (three) then return an error + if (currentHashes.length > allowedHashes.length) { + const allowedHashesMessage = allowedHashes + .map((hash) => hash.replace('file.hash.', '')) + .join(','); + return `There are more hash types than allowed [${allowedHashesMessage}]`; + } + + const hashesCount: { [key: string]: boolean } = {}; + const duplicatedHashes: string[] = []; + const invalidHash: string[] = []; + + // Check hash entries individually + currentHashes.forEach((hash) => { + if (!allowedHashes.includes(hash)) invalidHash.push(hash); + if (hashesCount[hash]) { + duplicatedHashes.push(hash); + } else { + hashesCount[hash] = true; + } + }); + + // There is more than one entry with the same hash type + if (duplicatedHashes.length) { + return `There are some duplicated hashes: ${duplicatedHashes.join(',')}`; + } + + // There is an entry with an invalid hash type + if (invalidHash.length) { + return `There are some invalid fields for hash type: ${invalidHash.join(',')}`; + } +}; + +// Validate there is only one entry when signer or path and the allowed entries for hashes +const entriesSchemaOptions = { + minSize: 1, + validate(entries: BlocklistConditionEntry[]) { + if (allowedHashes.includes(entries[0].field)) { + return hashEntriesValidation(entries); + } else { + if (entries.length > 1) { + return 'Only one entry is allowed when no using hash field type'; + } + } + }, +}; + +/* + * Entities array schema depending on Os type using schema.conditional. + * If OS === WINDOWS then use Windows schema, + * else if OS === LINUX then use Linux schema, + * else use Mac schema + * + * The validate function checks there is only one item for entries excepts for hash + */ +const EntriesSchema = schema.conditional( + schema.contextRef('os'), + OperatingSystem.WINDOWS, + schema.arrayOf(WindowsEntrySchema, entriesSchemaOptions), + schema.conditional( + schema.contextRef('os'), + OperatingSystem.LINUX, + schema.arrayOf(LinuxEntrySchema, entriesSchemaOptions), + schema.arrayOf(MacEntrySchema, entriesSchemaOptions) + ) +); + +/** + * Schema to validate Blocklist data for create and update. + * When called, it must be given an `context` with a `os` property set + * + * @example + * + * BlocklistDataSchema.validate(item, { os: 'windows' }); + */ +const BlocklistDataSchema = schema.object( + { + entries: EntriesSchema, + }, + + // Because we are only validating some fields from the Exception Item, we set `unknowns` to `ignore` here + { unknowns: 'ignore' } +); + +export class BlocklistValidator extends BaseValidator { + static isBlocklist(item: { listId: string }): boolean { + return item.listId === ENDPOINT_BLOCKLISTS_LIST_ID; + } + + async validatePreCreateItem( + item: CreateExceptionListItemOptions + ): Promise { + await this.validateCanManageEndpointArtifacts(); + await this.validateBlocklistData(item); + await this.validateCanCreateByPolicyArtifacts(item); + await this.validateByPolicyItem(item); + + return item; + } + + async validatePreDeleteItem(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreGetOneItem(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreMultiListFind(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreExport(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreSingleListFind(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreGetListSummary(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreUpdateItem( + _updatedItem: UpdateExceptionListItemOptions, + currentItem: ExceptionListItemSchema + ): Promise { + const updatedItem = _updatedItem as ExceptionItemLikeOptions; + + await this.validateCanManageEndpointArtifacts(); + await this.validateBlocklistData(updatedItem); + + try { + await this.validateCanCreateByPolicyArtifacts(updatedItem); + } catch (noByPolicyAuthzError) { + // Not allowed to create/update by policy data. Validate that the effective scope of the item + // remained unchanged with this update or was set to `global` (only allowed update). If not, + // then throw the validation error that was catch'ed + if (this.wasByPolicyEffectScopeChanged(updatedItem, currentItem)) { + throw noByPolicyAuthzError; + } + } + + await this.validateByPolicyItem(updatedItem); + + return _updatedItem; + } + + private async validateBlocklistData(item: ExceptionItemLikeOptions): Promise { + await this.validateBasicData(item); + + try { + BlocklistDataSchema.validate(item, { os: item.osTypes[0] }); + } catch (error) { + throw new EndpointArtifactExceptionValidationError(error.message); + } + } +} diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts index 05b3847001869..ccd6ebd8e08d6 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts @@ -8,3 +8,4 @@ export { TrustedAppValidator } from './trusted_app_validator'; export { EventFilterValidator } from './event_filter_validator'; export { HostIsolationExceptionsValidator } from './host_isolation_exceptions_validator'; +export { BlocklistValidator } from './blocklist_validator'; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts index dc539e76e7946..b2171ebd018bd 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts @@ -230,7 +230,7 @@ export class TrustedAppValidator extends BaseValidator { await this.validateByPolicyItem(updatedItem); - return updatedItem as UpdateExceptionListItemOptions; + return _updatedItem; } private async validateTrustedAppData(item: ExceptionItemLikeOptions): Promise { diff --git a/x-pack/plugins/session_view/common/constants.ts b/x-pack/plugins/session_view/common/constants.ts index 42e1d33ab6dba..9e8e1ae0d5e04 100644 --- a/x-pack/plugins/session_view/common/constants.ts +++ b/x-pack/plugins/session_view/common/constants.ts @@ -6,10 +6,11 @@ */ export const PROCESS_EVENTS_ROUTE = '/internal/session_view/process_events_route'; +export const ALERTS_ROUTE = '/internal/session_view/alerts_route'; export const ALERT_STATUS_ROUTE = '/internal/session_view/alert_status_route'; export const SESSION_ENTRY_LEADERS_ROUTE = '/internal/session_view/session_entry_leaders_route'; export const PROCESS_EVENTS_INDEX = 'logs-endpoint.events.process-default'; -export const ALERTS_INDEX = '.siem-signals-default'; +export const ALERTS_INDEX = '.alerts-security.alerts-default'; // TODO: changes to remove this and use AlertsClient instead to get indices. export const ENTRY_SESSION_ENTITY_ID_PROPERTY = 'process.entry_leader.entity_id'; export const ALERT_UUID_PROPERTY = 'kibana.alert.uuid'; export const KIBANA_DATE_FORMAT = 'MMM DD, YYYY @ hh:mm:ss.SSS'; diff --git a/x-pack/plugins/session_view/kibana.json b/x-pack/plugins/session_view/kibana.json index ff9d849016c55..4807315569d34 100644 --- a/x-pack/plugins/session_view/kibana.json +++ b/x-pack/plugins/session_view/kibana.json @@ -1,6 +1,6 @@ { "id": "sessionView", - "version": "8.0.0", + "version": "1.0.0", "kibanaVersion": "kibana", "owner": { "name": "Security Team", @@ -8,10 +8,11 @@ }, "requiredPlugins": [ "data", - "timelines" + "timelines", + "ruleRegistry" ], "requiredBundles": [ - "kibanaReact", + "kibanaReact", "esUiShared" ], "server": true, diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx new file mode 100644 index 0000000000000..1d0c9d0227699 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx @@ -0,0 +1,88 @@ +/* + * 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 { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { + DetailPanelAlertActions, + BUTTON_TEST_ID, + SHOW_DETAILS_TEST_ID, + JUMP_TO_PROCESS_TEST_ID, +} from './index'; +import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; +import userEvent from '@testing-library/user-event'; +import { ProcessImpl } from '../process_tree/hooks'; + +describe('DetailPanelAlertActions component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockShowAlertDetails = jest.fn((uuid) => uuid); + let mockOnProcessSelected = jest.fn((process) => process); + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockShowAlertDetails = jest.fn((uuid) => uuid); + mockOnProcessSelected = jest.fn((process) => process); + }); + + describe('When DetailPanelAlertActions is mounted', () => { + it('renders a popover when button is clicked', async () => { + const mockEvent = mockAlerts[0]; + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId(BUTTON_TEST_ID)); + expect(renderResult.queryByTestId(SHOW_DETAILS_TEST_ID)).toBeTruthy(); + expect(renderResult.queryByTestId(JUMP_TO_PROCESS_TEST_ID)).toBeTruthy(); + expect(mockShowAlertDetails.mock.calls.length).toBe(0); + expect(mockOnProcessSelected.mock.calls.length).toBe(0); + }); + + it('calls alert flyout callback when View details clicked', async () => { + const mockEvent = mockAlerts[0]; + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId(BUTTON_TEST_ID)); + userEvent.click(renderResult.getByTestId(SHOW_DETAILS_TEST_ID)); + expect(mockShowAlertDetails.mock.calls.length).toBe(1); + expect(mockShowAlertDetails.mock.results[0].value).toBe(mockEvent.kibana?.alert.uuid); + expect(mockOnProcessSelected.mock.calls.length).toBe(0); + }); + + it('calls onProcessSelected when Jump to process clicked', async () => { + const mockEvent = mockAlerts[0]; + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId(BUTTON_TEST_ID)); + userEvent.click(renderResult.getByTestId(JUMP_TO_PROCESS_TEST_ID)); + expect(mockOnProcessSelected.mock.calls.length).toBe(1); + expect(mockOnProcessSelected.mock.results[0].value).toBeInstanceOf(ProcessImpl); + expect(mockShowAlertDetails.mock.calls.length).toBe(0); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx new file mode 100644 index 0000000000000..4c7e3fdfaa961 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiPopover, EuiContextMenuPanel, EuiButtonIcon, EuiContextMenuItem } from '@elastic/eui'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { ProcessImpl } from '../process_tree/hooks'; + +export const BUTTON_TEST_ID = 'sessionView:detailPanelAlertActionsBtn'; +export const SHOW_DETAILS_TEST_ID = 'sessionView:detailPanelAlertActionShowDetails'; +export const JUMP_TO_PROCESS_TEST_ID = 'sessionView:detailPanelAlertActionJumpToProcess'; + +interface DetailPanelAlertActionsDeps { + event: ProcessEvent; + onShowAlertDetails: (alertId: string) => void; + onProcessSelected: (process: Process) => void; +} + +/** + * Detail panel alert context menu actions + */ +export const DetailPanelAlertActions = ({ + event, + onShowAlertDetails, + onProcessSelected, +}: DetailPanelAlertActionsDeps) => { + const [isPopoverOpen, setPopover] = useState(false); + + const onClosePopover = useCallback(() => { + setPopover(false); + }, []); + + const onToggleMenu = useCallback(() => { + setPopover(!isPopoverOpen); + }, [isPopoverOpen]); + + const onJumpToAlert = useCallback(() => { + const process = new ProcessImpl(event.process.entity_id); + process.addEvent(event); + + onProcessSelected(process); + setPopover(false); + }, [event, onProcessSelected]); + + const onShowDetails = useCallback(() => { + if (event.kibana) { + onShowAlertDetails(event.kibana.alert.uuid); + setPopover(false); + } + }, [event, onShowAlertDetails]); + + if (!event.kibana) { + return null; + } + + const { uuid } = event.kibana.alert; + + const menuItems = [ + + + , + + + , + ]; + + return ( + + } + isOpen={isPopoverOpen} + closePopover={onClosePopover} + panelPaddingSize="none" + anchorPosition="leftCenter" + > + + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts new file mode 100644 index 0000000000000..14d0be374b5d1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts @@ -0,0 +1,107 @@ +/* + * 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 { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject, css } from '@emotion/react'; + +interface StylesDeps { + minimal?: boolean; + isInvestigated?: boolean; +} + +export const useStyles = ({ minimal = false, isInvestigated = false }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, font, size, border } = euiTheme; + + const dangerBorder = transparentize(colors.danger, 0.2); + const dangerBackground = transparentize(colors.danger, 0.08); + const borderThickness = border.width.thin; + const mediumPadding = size.m; + + let alertTitleColor = colors.text; + let borderColor = colors.lightShade; + + if (isInvestigated) { + alertTitleColor = colors.primaryText; + borderColor = dangerBorder; + } + + const alertItem = css` + border: ${borderThickness} solid ${borderColor}; + padding: ${mediumPadding}; + border-radius: ${border.radius.medium}; + + margin: 0 ${mediumPadding} ${mediumPadding} ${mediumPadding}; + background-color: ${colors.emptyShade}; + + & .euiAccordion__buttonContent { + width: 100%; + } + + & .euiAccordion__button { + min-width: 0; + width: calc(100% - ${size.l}); + } + + & .euiAccordion__childWrapper { + overflow: visible; + } + `; + + const alertTitle: CSSObject = { + display: minimal ? 'none' : 'initial', + color: alertTitleColor, + fontWeight: font.weight.semiBold, + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }; + + const alertIcon: CSSObject = { + marginRight: size.s, + }; + + const alertAccordionButton: CSSObject = { + width: `calc(100% - ${size.l})`, + minWidth: 0, + }; + + const processPanel: CSSObject = { + border: `${borderThickness} solid ${colors.lightShade}`, + fontFamily: font.familyCode, + marginTop: mediumPadding, + padding: `${size.xs} ${size.s}`, + }; + + const investigatedLabel: CSSObject = { + position: 'relative', + zIndex: 1, + bottom: `-${mediumPadding}`, + left: `-${mediumPadding}`, + width: `calc(100% + ${mediumPadding} * 2)`, + borderTop: `${borderThickness} solid ${dangerBorder}`, + borderBottomLeftRadius: border.radius.medium, + borderBottomRightRadius: border.radius.medium, + backgroundColor: dangerBackground, + textAlign: 'center', + }; + + return { + alertItem, + alertTitle, + alertIcon, + alertAccordionButton, + processPanel, + investigatedLabel, + }; + }, [euiTheme, isInvestigated, minimal]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx new file mode 100644 index 0000000000000..daa472cd6e5b4 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.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, { useMemo } from 'react'; +import { EuiIcon, EuiText, EuiAccordion, EuiNotificationBadge } from '@elastic/eui'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { useStyles } from '../detail_panel_alert_list_item/styles'; +import { DetailPanelAlertListItem } from '../detail_panel_alert_list_item'; +import { ALERT_COUNT_THRESHOLD } from '../../../common/constants'; + +export const ALERT_GROUP_ITEM_TEST_ID = 'sessionView:detailPanelAlertGroupItem'; +export const ALERT_GROUP_ITEM_COUNT_TEST_ID = 'sessionView:detailPanelAlertGroupCount'; +export const ALERT_GROUP_ITEM_TITLE_TEST_ID = 'sessionView:detailPanelAlertGroupTitle'; + +interface DetailPanelAlertsGroupItemDeps { + alerts: ProcessEvent[]; + onProcessSelected: (process: Process) => void; + onShowAlertDetails: (alertId: string) => void; +} + +/** + * Detail panel description list item. + */ +export const DetailPanelAlertGroupItem = ({ + alerts, + onProcessSelected, + onShowAlertDetails, +}: DetailPanelAlertsGroupItemDeps) => { + const styles = useStyles(); + + const alertsCount = useMemo(() => { + return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length; + }, [alerts]); + + if (!alerts[0].kibana) { + return null; + } + + const { rule } = alerts[0].kibana.alert; + + return ( + +

+ + {rule.name} +

+ + } + css={styles.alertItem} + extraAction={ + + {alertsCount} + + } + > + {alerts.map((event) => { + const key = 'minimal_' + event.kibana?.alert.uuid; + + return ( + + ); + })} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx new file mode 100644 index 0000000000000..516d04539432e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx @@ -0,0 +1,137 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiSpacer, + EuiIcon, + EuiText, + EuiAccordion, + EuiPanel, + EuiHorizontalRule, +} from '@elastic/eui'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; +import { DetailPanelAlertActions } from '../detail_panel_alert_actions'; + +export const ALERT_LIST_ITEM_TEST_ID = 'sessionView:detailPanelAlertListItem'; +export const ALERT_LIST_ITEM_ARGS_TEST_ID = 'sessionView:detailPanelAlertListItemArgs'; +export const ALERT_LIST_ITEM_TIMESTAMP_TEST_ID = 'sessionView:detailPanelAlertListItemTimestamp'; + +interface DetailPanelAlertsListItemDeps { + event: ProcessEvent; + onShowAlertDetails: (alertId: string) => void; + onProcessSelected: (process: Process) => void; + isInvestigated?: boolean; + minimal?: boolean; +} + +/** + * Detail panel description list item. + */ +export const DetailPanelAlertListItem = ({ + event, + onProcessSelected, + onShowAlertDetails, + isInvestigated, + minimal, +}: DetailPanelAlertsListItemDeps) => { + const styles = useStyles(minimal, isInvestigated); + + if (!event.kibana) { + return null; + } + + const timestamp = event['@timestamp']; + const { uuid, name } = event.kibana.alert.rule; + const { args } = event.process; + + const forceState = !isInvestigated ? 'open' : undefined; + + return minimal ? ( +
+ + + + + {timestamp} + + + + + + + + {args.join(' ')} + + +
+ ) : ( + +

+ + {name} +

+ + } + initialIsOpen={true} + forceState={forceState} + css={styles.alertItem} + extraAction={ + + } + > + + + {timestamp} + + + + {args.join(' ')} + + + {isInvestigated && ( +
+ + + +
+ )} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts new file mode 100644 index 0000000000000..7672bb942ff32 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject, css } from '@emotion/react'; + +export const useStyles = (minimal = false, isInvestigated = false) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, font, size, border } = euiTheme; + + const dangerBorder = transparentize(colors.danger, 0.2); + const dangerBackground = transparentize(colors.danger, 0.08); + const borderThickness = border.width.thin; + const mediumPadding = size.m; + + let alertTitleColor = colors.text; + let borderColor = colors.lightShade; + + if (isInvestigated) { + alertTitleColor = colors.primaryText; + borderColor = dangerBorder; + } + + const alertItem = css` + border: ${borderThickness} solid ${borderColor}; + padding: ${mediumPadding}; + border-radius: ${border.radius.medium}; + + margin: 0 ${mediumPadding} ${mediumPadding} ${mediumPadding}; + background-color: ${colors.emptyShade}; + + & .euiAccordion__buttonContent { + width: 100%; + } + + & .euiAccordion__button { + min-width: 0; + width: calc(100% - ${size.l}); + } + + & .euiAccordion__childWrapper { + overflow: visible; + } + `; + + const alertTitle: CSSObject = { + display: minimal ? 'none' : 'initial', + color: alertTitleColor, + fontWeight: font.weight.semiBold, + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }; + + const alertIcon: CSSObject = { + marginRight: size.s, + }; + + const alertAccordionButton: CSSObject = { + width: `calc(100% - ${size.l})`, + minWidth: 0, + }; + + const processPanel: CSSObject = { + border: `${borderThickness} solid ${colors.lightShade}`, + fontFamily: font.familyCode, + marginTop: minimal ? size.s : size.m, + padding: `${size.xs} ${size.s}`, + }; + + const investigatedLabel: CSSObject = { + position: 'relative', + zIndex: 1, + bottom: `-${mediumPadding}`, + left: `-${mediumPadding}`, + width: `calc(100% + ${mediumPadding} * 2)`, + borderTop: `${borderThickness} solid ${dangerBorder}`, + borderBottomLeftRadius: border.radius.medium, + borderBottomRightRadius: border.radius.medium, + backgroundColor: dangerBackground, + textAlign: 'center', + }; + + const minimalContextMenu: CSSObject = { + float: 'right', + }; + + const minimalHR: CSSObject = { + marginBottom: 0, + }; + + return { + alertItem, + alertTitle, + alertIcon, + alertAccordionButton, + processPanel, + investigatedLabel, + minimalContextMenu, + minimalHR, + }; + }, [euiTheme, isInvestigated, minimal]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx new file mode 100644 index 0000000000000..a915f8e285ad1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx @@ -0,0 +1,251 @@ +/* + * 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 { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelAlertTab } from './index'; +import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; +import { fireEvent } from '@testing-library/dom'; +import { + INVESTIGATED_ALERT_TEST_ID, + VIEW_MODE_TOGGLE, + ALERTS_TAB_EMPTY_STATE_TEST_ID, +} from './index'; +import { + ALERT_LIST_ITEM_TEST_ID, + ALERT_LIST_ITEM_ARGS_TEST_ID, + ALERT_LIST_ITEM_TIMESTAMP_TEST_ID, +} from '../detail_panel_alert_list_item/index'; +import { + ALERT_GROUP_ITEM_TEST_ID, + ALERT_GROUP_ITEM_COUNT_TEST_ID, + ALERT_GROUP_ITEM_TITLE_TEST_ID, +} from '../detail_panel_alert_group_item/index'; + +const ACCORDION_BUTTON_CLASS = '.euiAccordion__button'; +const VIEW_MODE_GROUP = 'groupView'; +const ARIA_EXPANDED_ATTR = 'aria-expanded'; + +describe('DetailPanelAlertTab component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockOnProcessSelected = jest.fn((process) => process); + let mockShowAlertDetails = jest.fn((alertId) => alertId); + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockOnProcessSelected = jest.fn((process) => process); + mockShowAlertDetails = jest.fn((alertId) => alertId); + }); + + describe('When DetailPanelAlertTab is mounted', () => { + it('renders a list of alerts for the session (defaulting to list view mode)', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TEST_ID).length).toBe(mockAlerts.length); + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TEST_ID)).toBeFalsy(); + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeFalsy(); + expect( + renderResult + .queryByTestId(VIEW_MODE_TOGGLE) + ?.querySelector('.euiButtonGroupButton-isSelected')?.textContent + ).toBe('List view'); + }); + + it('renders a list of alerts grouped by rule when group-view clicked', async () => { + renderResult = mockedContext.render( + + ); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TEST_ID).length).toBe(mockAlerts.length); + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TEST_ID)).toBeTruthy(); + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeFalsy(); + expect( + renderResult + .queryByTestId(VIEW_MODE_TOGGLE) + ?.querySelector('.euiButtonGroupButton-isSelected')?.textContent + ).toBe('Group view'); + }); + + it('renders a sticky investigated alert (outside of main list) if one is set', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeTruthy(); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeTruthy(); + }); + + it('investigated alert should be collapsible', async () => { + renderResult = mockedContext.render( + + ); + + expect( + renderResult + .queryByTestId(INVESTIGATED_ALERT_TEST_ID) + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + + const expandButton = renderResult + .queryByTestId(INVESTIGATED_ALERT_TEST_ID) + ?.querySelector(ACCORDION_BUTTON_CLASS); + + if (expandButton) { + fireEvent.click(expandButton); + } + + expect( + renderResult + .queryByTestId(INVESTIGATED_ALERT_TEST_ID) + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('false'); + }); + + it('non investigated alert should NOT be collapsible', async () => { + renderResult = mockedContext.render( + + ); + + expect( + renderResult + .queryAllByTestId(ALERT_LIST_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + + const expandButton = renderResult + .queryAllByTestId(ALERT_LIST_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS); + + if (expandButton) { + fireEvent.click(expandButton); + } + + expect( + renderResult + .queryAllByTestId(ALERT_LIST_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + }); + + it('grouped alerts should be expandable/collapsible (default to collapsed)', async () => { + renderResult = mockedContext.render( + + ); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect( + renderResult + .queryAllByTestId(ALERT_GROUP_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('false'); + + const expandButton = renderResult + .queryAllByTestId(ALERT_GROUP_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS); + + if (expandButton) { + fireEvent.click(expandButton); + } + + expect( + renderResult + .queryAllByTestId(ALERT_GROUP_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + }); + + it('each alert list item should show a timestamp and process arguments', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TIMESTAMP_TEST_ID)[0]).toHaveTextContent( + mockAlerts[0]['@timestamp'] + ); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_ARGS_TEST_ID)[0]).toHaveTextContent( + mockAlerts[0].process.args.join(' ') + ); + }); + + it('each alert group should show a rule title and alert count', async () => { + renderResult = mockedContext.render( + + ); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_COUNT_TEST_ID)).toHaveTextContent('2'); + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TITLE_TEST_ID)).toHaveTextContent( + mockAlerts[0].kibana?.alert.rule.name || '' + ); + }); + + it('renders an empty state when there are no alerts', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId(ALERTS_TAB_EMPTY_STATE_TEST_ID)).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx new file mode 100644 index 0000000000000..7fa47f4f5daf7 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx @@ -0,0 +1,146 @@ +/* + * 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, { useState, useMemo } from 'react'; +import { EuiEmptyPrompt, EuiButtonGroup, EuiHorizontalRule } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { groupBy } from 'lodash'; +import { ProcessEvent, Process } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; +import { DetailPanelAlertListItem } from '../detail_panel_alert_list_item'; +import { DetailPanelAlertGroupItem } from '../detail_panel_alert_group_item'; + +export const ALERTS_TAB_EMPTY_STATE_TEST_ID = 'sessionView:detailPanelAlertsEmptyState'; +export const INVESTIGATED_ALERT_TEST_ID = 'sessionView:detailPanelInvestigatedAlert'; +export const VIEW_MODE_TOGGLE = 'sessionView:detailPanelAlertsViewMode'; + +interface DetailPanelAlertTabDeps { + alerts: ProcessEvent[]; + onProcessSelected: (process: Process) => void; + onShowAlertDetails: (alertId: string) => void; + investigatedAlert?: ProcessEvent; +} + +const VIEW_MODE_LIST = 'listView'; +const VIEW_MODE_GROUP = 'groupView'; + +/** + * Host Panel of session view detail panel. + */ +export const DetailPanelAlertTab = ({ + alerts, + onProcessSelected, + onShowAlertDetails, + investigatedAlert, +}: DetailPanelAlertTabDeps) => { + const styles = useStyles(); + const [viewMode, setViewMode] = useState(VIEW_MODE_LIST); + const viewModes = [ + { + id: VIEW_MODE_LIST, + label: i18n.translate('xpack.sessionView.alertDetailsTab.listView', { + defaultMessage: 'List view', + }), + }, + { + id: VIEW_MODE_GROUP, + label: i18n.translate('xpack.sessionView.alertDetailsTab.groupView', { + defaultMessage: 'Group view', + }), + }, + ]; + + const filteredAlerts = useMemo(() => { + return alerts.filter((event) => { + const isInvestigatedAlert = + event.kibana?.alert.uuid === investigatedAlert?.kibana?.alert.uuid; + return !isInvestigatedAlert; + }); + }, [investigatedAlert, alerts]); + + const groupedAlerts = useMemo(() => { + return groupBy(filteredAlerts, (event) => event.kibana?.alert.rule.uuid); + }, [filteredAlerts]); + + if (alerts.length === 0) { + return ( + + + + } + body={ +

+ +

+ } + /> + ); + } + + return ( +
+ + {investigatedAlert && ( +
+ + +
+ )} + + {viewMode === VIEW_MODE_LIST + ? filteredAlerts.map((event) => { + const key = event.kibana?.alert.uuid; + + return ( + + ); + }) + : Object.keys(groupedAlerts).map((ruleId: string) => { + const alertsByRule = groupedAlerts[ruleId]; + + return ( + + ); + })} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts new file mode 100644 index 0000000000000..a906744cdafb2 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, size } = euiTheme; + + const container: CSSObject = { + position: 'relative', + }; + + const stickyItem: CSSObject = { + position: 'sticky', + top: 0, + zIndex: 1, + backgroundColor: colors.emptyShade, + paddingTop: size.base, + }; + + const viewMode: CSSObject = { + margin: size.base, + marginBottom: 0, + }; + + return { + container, + stickyItem, + viewMode, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts index fb00344d5e280..2b7f78e88fafb 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts @@ -21,12 +21,14 @@ import { processNewEvents, searchProcessTree, autoExpandProcessTree, + updateProcessMap, } from './helpers'; import { sortProcesses } from '../../../common/utils/sort_processes'; interface UseProcessTreeDeps { sessionEntityId: string; data: ProcessEventsPage[]; + alerts: ProcessEvent[]; searchQuery?: string; updatedAlertsStatus: AlertStatusEventEntityIdMap; } @@ -196,6 +198,7 @@ export class ProcessImpl implements Process { export const useProcessTree = ({ sessionEntityId, data, + alerts, searchQuery, updatedAlertsStatus, }: UseProcessTreeDeps) => { @@ -221,6 +224,7 @@ export const useProcessTree = ({ const [processMap, setProcessMap] = useState(initializedProcessMap); const [processedPages, setProcessedPages] = useState([]); + const [alertsProcessed, setAlertsProcessed] = useState(false); const [searchResults, setSearchResults] = useState([]); const [orphans, setOrphans] = useState([]); @@ -257,6 +261,15 @@ export const useProcessTree = ({ } }, [data, processMap, orphans, processedPages, sessionEntityId]); + useEffect(() => { + // currently we are loading a single page of alerts, with no pagination + // so we only need to add these alert events to processMap once. + if (!alertsProcessed) { + updateProcessMap(processMap, alerts); + setAlertsProcessed(true); + } + }, [processMap, alerts, alertsProcessed]); + useEffect(() => { setSearchResults(searchProcessTree(processMap, searchQuery)); autoExpandProcessTree(processMap); diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx index 9fa7900d04b0d..3c0b9c5d0d4d9 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { mockData } from '../../../common/mocks/constants/session_view_process.mock'; +import { mockData, mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; import { Process } from '../../../common/types/process_tree'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; import { ProcessImpl } from './hooks'; @@ -21,6 +21,7 @@ describe('ProcessTree component', () => { const props: ProcessTreeDeps = { sessionEntityId: sessionLeader.process.entity_id, data: mockData, + alerts: mockAlerts, isFetching: false, fetchNextPage: jest.fn(), hasNextPage: false, @@ -28,7 +29,7 @@ describe('ProcessTree component', () => { hasPreviousPage: false, onProcessSelected: jest.fn(), updatedAlertsStatus: {}, - handleOnAlertDetailsClosed: jest.fn(), + onShowAlertDetails: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.tsx index 4b489797c7e26..1e10e58d1cca0 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.tsx @@ -26,6 +26,7 @@ export interface ProcessTreeDeps { sessionEntityId: string; data: ProcessEventsPage[]; + alerts: ProcessEvent[]; jumpToEvent?: ProcessEvent; isFetching: boolean; @@ -44,8 +45,7 @@ export interface ProcessTreeDeps { // a map for alerts with updated status and process.entity_id updatedAlertsStatus: AlertStatusEventEntityIdMap; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string) => void; + onShowAlertDetails: (alertUuid: string) => void; timeStampOn?: boolean; verboseModeOn?: boolean; } @@ -53,6 +53,7 @@ export interface ProcessTreeDeps { export const ProcessTree = ({ sessionEntityId, data, + alerts, jumpToEvent, isFetching, hasNextPage, @@ -64,8 +65,7 @@ export const ProcessTree = ({ onProcessSelected, setSearchResults, updatedAlertsStatus, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, timeStampOn, verboseModeOn, }: ProcessTreeDeps) => { @@ -76,6 +76,7 @@ export const ProcessTree = ({ const { sessionLeader, processMap, searchResults } = useProcessTree({ sessionEntityId, data, + alerts, searchQuery, updatedAlertsStatus, }); @@ -203,8 +204,7 @@ export const ProcessTree = ({ selectedProcessId={selectedProcess?.id} scrollerRef={scrollerRef} onChangeJumpToEventVisibility={onChangeJumpToEventVisibility} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} timeStampOn={timeStampOn} verboseModeOn={verboseModeOn} /> diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx index 2a56a0ae2be67..c1b0c807528ec 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx @@ -26,7 +26,7 @@ describe('ProcessTreeAlerts component', () => { isSelected: false, onClick: jest.fn(), selectAlert: jest.fn(), - handleOnAlertDetailsClosed: jest.fn(), + onShowAlertDetails: jest.fn(), }; beforeEach(() => { @@ -61,16 +61,16 @@ describe('ProcessTreeAlerts component', () => { expect(selectAlert).toHaveBeenCalledTimes(1); }); - it('should execute loadAlertDetails callback when clicking on expand button', async () => { - const loadAlertDetails = jest.fn(); + it('should execute onShowAlertDetails callback when clicking on expand button', async () => { + const onShowAlertDetails = jest.fn(); renderResult = mockedContext.render( - + ); const expandButton = renderResult.queryByTestId(EXPAND_BUTTON_TEST_ID); expect(expandButton).toBeTruthy(); expandButton?.click(); - expect(loadAlertDetails).toHaveBeenCalledTimes(1); + expect(onShowAlertDetails).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx index 5ec1c4a7693c3..30892d02c5428 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx @@ -17,8 +17,7 @@ export interface ProcessTreeAlertDeps { isSelected: boolean; onClick: (alert: ProcessEventAlert | null) => void; selectAlert: (alertUuid: string) => void; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string, status?: string) => void; + onShowAlertDetails: (alertUuid: string) => void; } export const ProcessTreeAlert = ({ @@ -27,8 +26,7 @@ export const ProcessTreeAlert = ({ isSelected, onClick, selectAlert, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, }: ProcessTreeAlertDeps) => { const styles = useStyles({ isInvestigated, isSelected }); @@ -41,10 +39,10 @@ export const ProcessTreeAlert = ({ }, [isInvestigated, uuid, selectAlert]); const handleExpandClick = useCallback(() => { - if (loadAlertDetails && uuid) { - loadAlertDetails(uuid, () => handleOnAlertDetailsClosed(uuid)); + if (uuid) { + onShowAlertDetails(uuid); } - }, [handleOnAlertDetailsClosed, loadAlertDetails, uuid]); + }, [onShowAlertDetails, uuid]); const handleClick = useCallback(() => { if (alert.kibana?.alert) { diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx index 2333c71d36a51..ee6866f6a8a60 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx @@ -17,7 +17,7 @@ describe('ProcessTreeAlerts component', () => { const props: ProcessTreeAlertsDeps = { alerts: mockAlerts, onAlertSelected: jest.fn(), - handleOnAlertDetailsClosed: jest.fn(), + onShowAlertDetails: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx index c97ccfe253605..b51d58bf825ec 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx @@ -16,8 +16,7 @@ export interface ProcessTreeAlertsDeps { jumpToAlertID?: string; isProcessSelected?: boolean; onAlertSelected: (e: MouseEvent) => void; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string) => void; + onShowAlertDetails: (alertUuid: string) => void; } export function ProcessTreeAlerts({ @@ -25,8 +24,7 @@ export function ProcessTreeAlerts({ jumpToAlertID, isProcessSelected = false, onAlertSelected, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, }: ProcessTreeAlertsDeps) { const [selectedAlert, setSelectedAlert] = useState(null); const styles = useStyles(); @@ -90,8 +88,7 @@ export function ProcessTreeAlerts({ isSelected={isProcessSelected && selectedAlert?.uuid === alertUuid} onClick={handleAlertClick} selectAlert={selectAlert} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} /> ); })} diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx index 2e82e822f0c82..5c3b790ad0430 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx @@ -36,7 +36,7 @@ describe('ProcessTreeNode component', () => { }, } as unknown as RefObject, onChangeJumpToEventVisibility: jest.fn(), - handleOnAlertDetailsClosed: (_alertUuid: string) => {}, + onShowAlertDetails: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx index b1c42dd95efb9..387e7a5074699 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -21,7 +21,8 @@ import React, { useMemo, RefObject, } from 'react'; -import { EuiButton, EuiIcon, formatDate } from '@elastic/eui'; +import { EuiButton, EuiIcon, EuiToolTip, formatDate } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { Process } from '../../../common/types/process_tree'; import { useVisible } from '../../hooks/use_visible'; @@ -43,8 +44,7 @@ export interface ProcessDeps { verboseModeOn?: boolean; scrollerRef: RefObject; onChangeJumpToEventVisibility: (isVisible: boolean, isAbove: boolean) => void; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string) => void; + onShowAlertDetails: (alertUuid: string) => void; } /** @@ -62,8 +62,7 @@ export function ProcessTreeNode({ verboseModeOn = true, scrollerRef, onChangeJumpToEventVisibility, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, }: ProcessDeps) { const textRef = useRef(null); @@ -144,6 +143,33 @@ export function ProcessTreeNode({ ); const processDetails = process.getDetails(); + const hasExec = process.hasExec(); + + const processIcon = useMemo(() => { + if (!process.parent) { + return 'unlink'; + } else if (hasExec) { + return 'console'; + } else { + return 'branch'; + } + }, [hasExec, process.parent]); + + const iconTooltip = useMemo(() => { + if (!process.parent) { + return i18n.translate('xpack.sessionView.processNode.tooltipOrphan', { + defaultMessage: 'Process missing parent (orphan)', + }); + } else if (hasExec) { + return i18n.translate('xpack.sessionView.processNode.tooltipExec', { + defaultMessage: "Process exec'd", + }); + } else { + return i18n.translate('xpack.sessionView.processNode.tooltipFork', { + defaultMessage: 'Process forked (no exec)', + }); + } + }, [hasExec, process.parent]); if (!processDetails?.process) { return null; @@ -169,11 +195,9 @@ export function ProcessTreeNode({ const showUserEscalation = user.id !== parent.user.id; const interactiveSession = !!tty; const sessionIcon = interactiveSession ? 'consoleApp' : 'compute'; - const hasExec = process.hasExec(); const iconTestSubj = hasExec ? 'sessionView:processTreeNodeExecIcon' : 'sessionView:processTreeNodeForkIcon'; - const processIcon = hasExec ? 'console' : 'branch'; const timeStampsNormal = formatDate(start, KIBANA_DATE_FORMAT); @@ -200,7 +224,9 @@ export function ProcessTreeNode({ ) : ( - + + + {' '} {workingDirectory}  {args[0]}  @@ -255,8 +281,7 @@ export function ProcessTreeNode({ jumpToAlertID={jumpToAlertID} isProcessSelected={selectedProcessId === process.id} onAlertSelected={onProcessClicked} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} /> )} @@ -276,8 +301,7 @@ export function ProcessTreeNode({ verboseModeOn={verboseModeOn} scrollerRef={scrollerRef} onChangeJumpToEventVisibility={onChangeJumpToEventVisibility} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} /> ); })} diff --git a/x-pack/plugins/session_view/public/components/session_view/hooks.ts b/x-pack/plugins/session_view/public/components/session_view/hooks.ts index a134a366c4168..e48b3a335dbd3 100644 --- a/x-pack/plugins/session_view/public/components/session_view/hooks.ts +++ b/x-pack/plugins/session_view/public/components/session_view/hooks.ts @@ -15,9 +15,11 @@ import { ProcessEventResults, } from '../../../common/types/process_tree'; import { + ALERTS_ROUTE, PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE, ALERT_STATUS_ROUTE, + QUERY_KEY_PROCESS_EVENTS, QUERY_KEY_ALERTS, } from '../../../common/constants'; @@ -28,9 +30,10 @@ export const useFetchSessionViewProcessEvents = ( const { http } = useKibana().services; const [isJumpToFirstPage, setIsJumpToFirstPage] = useState(false); const jumpToCursor = jumpToEvent && jumpToEvent.process.start; + const cachingKeys = [QUERY_KEY_PROCESS_EVENTS, sessionEntityId]; const query = useInfiniteQuery( - 'sessionViewProcessEvents', + cachingKeys, async ({ pageParam = {} }) => { let { cursor } = pageParam; const { forward } = pageParam; @@ -52,18 +55,18 @@ export const useFetchSessionViewProcessEvents = ( return { events, cursor }; }, { - getNextPageParam: (lastPage, pages) => { + getNextPageParam: (lastPage) => { if (lastPage.events.length === PROCESS_EVENTS_PER_PAGE) { return { - cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'], + cursor: lastPage.events[lastPage.events.length - 1].process.start, forward: true, }; } }, - getPreviousPageParam: (firstPage, pages) => { + getPreviousPageParam: (firstPage) => { if (jumpToEvent && firstPage.events.length === PROCESS_EVENTS_PER_PAGE) { return { - cursor: firstPage.events[0]['@timestamp'], + cursor: firstPage.events[0].process.start, forward: false, }; } @@ -84,6 +87,32 @@ export const useFetchSessionViewProcessEvents = ( return query; }; +export const useFetchSessionViewAlerts = (sessionEntityId: string) => { + const { http } = useKibana().services; + const cachingKeys = [QUERY_KEY_ALERTS, sessionEntityId]; + const query = useQuery( + cachingKeys, + async () => { + const res = await http.get(ALERTS_ROUTE, { + query: { + sessionEntityId, + }, + }); + + const events = res.events.map((event: any) => event._source as ProcessEvent); + + return events; + }, + { + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + } + ); + + return query; +}; + export const useFetchAlertStatus = ( updatedAlertsStatus: AlertStatusEventEntityIdMap, alertUuid: string diff --git a/x-pack/plugins/session_view/public/components/session_view/index.tsx b/x-pack/plugins/session_view/public/components/session_view/index.tsx index af4eb6114a0a2..ee481c4204108 100644 --- a/x-pack/plugins/session_view/public/components/session_view/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view/index.tsx @@ -23,7 +23,11 @@ import { SessionViewDetailPanel } from '../session_view_detail_panel'; import { SessionViewSearchBar } from '../session_view_search_bar'; import { SessionViewDisplayOptions } from '../session_view_display_options'; import { useStyles } from './styles'; -import { useFetchAlertStatus, useFetchSessionViewProcessEvents } from './hooks'; +import { + useFetchAlertStatus, + useFetchSessionViewProcessEvents, + useFetchSessionViewAlerts, +} from './hooks'; /** * The main wrapper component for the session view. @@ -61,8 +65,12 @@ export const SessionView = ({ hasPreviousPage, } = useFetchSessionViewProcessEvents(sessionEntityId, jumpToEvent); - const hasData = data && data.pages.length > 0 && data.pages[0].events.length > 0; - const renderIsLoading = isFetching && !data; + const alertsQuery = useFetchSessionViewAlerts(sessionEntityId); + const { data: alerts, error: alertsError, isFetching: alertsFetching } = alertsQuery; + + const hasData = alerts && data && data.pages?.[0].events.length > 0; + const hasError = error || alertsError; + const renderIsLoading = (isFetching || alertsFetching) && !data; const renderDetails = isDetailOpen && selectedProcess; const { data: newUpdatedAlertsStatus } = useFetchAlertStatus( updatedAlertsStatus, @@ -83,6 +91,15 @@ export const SessionView = ({ setIsDetailOpen(!isDetailOpen); }, [isDetailOpen]); + const onShowAlertDetails = useCallback( + (alertUuid: string) => { + if (loadAlertDetails) { + loadAlertDetails(alertUuid, () => handleOnAlertDetailsClosed(alertUuid)); + } + }, + [loadAlertDetails, handleOnAlertDetailsClosed] + ); + const handleOptionChange = useCallback((checkedOptions: DisplayOptionsState) => { setDisplayOptions(checkedOptions); }, []); @@ -165,7 +182,7 @@ export const SessionView = ({ )} - {error && ( + {hasError && ( @@ -215,7 +232,7 @@ export const SessionView = ({ {renderDetails ? ( <> - + diff --git a/x-pack/plugins/session_view/public/components/session_view/styles.ts b/x-pack/plugins/session_view/public/components/session_view/styles.ts index d2c87130bfa4b..edfe2356d5aa2 100644 --- a/x-pack/plugins/session_view/public/components/session_view/styles.ts +++ b/x-pack/plugins/session_view/public/components/session_view/styles.ts @@ -17,6 +17,10 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { const { euiTheme } = useEuiTheme(); const cached = useMemo(() => { + const { border, colors } = euiTheme; + + const thinBorder = `${border.width.thin} solid ${colors.lightShade}!important`; + const processTree: CSSObject = { height: `${height}px`, position: 'relative', @@ -24,6 +28,12 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { const detailPanel: CSSObject = { height: `${height}px`, + borderLeft: thinBorder, + borderRight: thinBorder, + }; + + const resizeHandle: CSSObject = { + zIndex: 2, }; const searchBar: CSSObject = { @@ -38,6 +48,7 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { return { processTree, detailPanel, + resizeHandle, searchBar, buttonsEyeDetail, }; diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx index f754086fe5fab..40e71efd8a6cf 100644 --- a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx @@ -6,7 +6,10 @@ */ import React from 'react'; -import { sessionViewBasicProcessMock } from '../../../common/mocks/constants/session_view_process.mock'; +import { + mockAlerts, + sessionViewBasicProcessMock, +} from '../../../common/mocks/constants/session_view_process.mock'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; import { SessionViewDetailPanel } from './index'; @@ -14,27 +17,66 @@ describe('SessionView component', () => { let render: () => ReturnType; let renderResult: ReturnType; let mockedContext: AppContextTestRender; + let mockOnProcessSelected = jest.fn((process) => process); + let mockShowAlertDetails = jest.fn((alertId) => alertId); beforeEach(() => { mockedContext = createAppRootMockRenderer(); + mockOnProcessSelected = jest.fn((process) => process); + mockShowAlertDetails = jest.fn((alertId) => alertId); }); describe('When SessionViewDetailPanel is mounted', () => { it('shows process detail by default', async () => { renderResult = mockedContext.render( - + ); expect(renderResult.queryByText('8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726')).toBeVisible(); }); it('can switch tabs to show host details', async () => { renderResult = mockedContext.render( - + ); renderResult.queryByText('Host')?.click(); expect(renderResult.queryByText('hostname')).toBeVisible(); expect(renderResult.queryAllByText('james-fleet-714-2')).toHaveLength(2); }); + + it('can switch tabs to show alert details', async () => { + renderResult = mockedContext.render( + + ); + + renderResult.queryByText('Alerts')?.click(); + expect(renderResult.queryByText('List view')).toBeVisible(); + }); + it('alert tab disabled when no alerts', async () => { + renderResult = mockedContext.render( + + ); + + renderResult.queryByText('Alerts')?.click(); + expect(renderResult.queryByText('List view')).toBeFalsy(); + }); }); }); diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx index a47ce1d91ac97..51eb65a38f835 100644 --- a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx @@ -6,50 +6,91 @@ */ import React, { useState, useMemo, useCallback } from 'react'; import { EuiTabs, EuiTab, EuiNotificationBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { EuiTabProps } from '../../types'; -import { Process } from '../../../common/types/process_tree'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; import { getDetailPanelProcess, getSelectedTabContent } from './helpers'; import { DetailPanelProcessTab } from '../detail_panel_process_tab'; import { DetailPanelHostTab } from '../detail_panel_host_tab'; +import { DetailPanelAlertTab } from '../detail_panel_alert_tab'; +import { ALERT_COUNT_THRESHOLD } from '../../../common/constants'; interface SessionViewDetailPanelDeps { selectedProcess: Process; - onProcessSelected?: (process: Process) => void; + alerts?: ProcessEvent[]; + investigatedAlert?: ProcessEvent; + onProcessSelected: (process: Process) => void; + onShowAlertDetails: (alertId: string) => void; } /** * Detail panel in the session view. */ -export const SessionViewDetailPanel = ({ selectedProcess }: SessionViewDetailPanelDeps) => { +export const SessionViewDetailPanel = ({ + alerts, + selectedProcess, + investigatedAlert, + onProcessSelected, + onShowAlertDetails, +}: SessionViewDetailPanelDeps) => { const [selectedTabId, setSelectedTabId] = useState('process'); const processDetail = useMemo(() => getDetailPanelProcess(selectedProcess), [selectedProcess]); - const tabs: EuiTabProps[] = useMemo( - () => [ + const alertsCount = useMemo(() => { + if (!alerts) { + return 0; + } + + return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length; + }, [alerts]); + + const tabs: EuiTabProps[] = useMemo(() => { + const hasAlerts = !!alerts?.length; + + return [ { id: 'process', - name: 'Process', + name: i18n.translate('xpack.sessionView.detailsPanel.process', { + defaultMessage: 'Process', + }), content: , }, { id: 'host', - name: 'Host', + name: i18n.translate('xpack.sessionView.detailsPanel.host', { + defaultMessage: 'Host', + }), content: , }, { id: 'alerts', - disabled: true, - name: 'Alerts', - append: ( + name: i18n.translate('xpack.sessionView.detailsPanel.alerts', { + defaultMessage: 'Alerts', + }), + append: hasAlerts && ( - 10 + {alertsCount} ), - content: null, + content: alerts && ( + + ), }, - ], - [processDetail, selectedProcess.events] - ); + ]; + }, [ + alerts, + alertsCount, + processDetail, + selectedProcess.events, + onProcessSelected, + onShowAlertDetails, + investigatedAlert, + ]); const onSelectedTabChanged = useCallback((id: string) => { setSelectedTabId(id); diff --git a/x-pack/plugins/session_view/server/plugin.ts b/x-pack/plugins/session_view/server/plugin.ts index c7fd511b3de05..7347f7676af62 100644 --- a/x-pack/plugins/session_view/server/plugin.ts +++ b/x-pack/plugins/session_view/server/plugin.ts @@ -11,12 +11,14 @@ import { Plugin, Logger, PluginInitializerContext, + IRouter, } from '../../../../src/core/server'; import { SessionViewSetupPlugins, SessionViewStartPlugins } from './types'; import { registerRoutes } from './routes'; export class SessionViewPlugin implements Plugin { private logger: Logger; + private router: IRouter | undefined; /** * Initialize SessionViewPlugin class properties (logger, etc) that is accessible @@ -28,14 +30,16 @@ export class SessionViewPlugin implements Plugin { public setup(core: CoreSetup, plugins: SessionViewSetupPlugins) { this.logger.debug('session view: Setup'); - const router = core.http.createRouter(); - - // Register server routes - registerRoutes(router); + this.router = core.http.createRouter(); } public start(core: CoreStart, plugins: SessionViewStartPlugins) { this.logger.debug('session view: Start'); + + // Register server routes + if (this.router) { + registerRoutes(this.router, plugins.ruleRegistry); + } } public stop() { diff --git a/x-pack/plugins/session_view/server/routes/alerts_route.test.ts b/x-pack/plugins/session_view/server/routes/alerts_route.test.ts new file mode 100644 index 0000000000000..4c8ee6fb2c83e --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/alerts_route.test.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + ALERT_RULE_CONSUMER, + ALERT_RULE_TYPE_ID, + SPACE_IDS, + ALERT_WORKFLOW_STATUS, +} from '@kbn/rule-data-utils'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { doSearch } from './alerts_route'; +import { mockEvents } from '../../common/mocks/constants/session_view_process.mock'; + +import { + AlertsClient, + ConstructorOptions, +} from '../../../rule_registry/server/alert_data_client/alerts_client'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { alertingAuthorizationMock } from '../../../alerting/server/authorization/alerting_authorization.mock'; +import { auditLoggerMock } from '../../../security/server/audit/mocks'; +import { AlertingAuthorizationEntity } from '../../../alerting/server'; +import { ruleDataServiceMock } from '../../../rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock'; + +const alertingAuthMock = alertingAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); + +const DEFAULT_SPACE = 'test_default_space_id'; + +const getEmptyResponse = async () => { + return { + hits: { + total: 0, + hits: [], + }, + }; +}; + +const getResponse = async () => { + return { + hits: { + total: mockEvents.length, + hits: mockEvents.map((event) => { + return { + found: true, + _type: 'alert', + _index: '.alerts-security', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 1, + _seq_no: 362, + _primary_term: 2, + _source: { + [ALERT_RULE_TYPE_ID]: 'apm.error_rate', + message: 'hello world 1', + [ALERT_RULE_CONSUMER]: 'apm', + [ALERT_WORKFLOW_STATUS]: 'open', + [SPACE_IDS]: ['test_default_space_id'], + ...event, + }, + }; + }), + }, + }; +}; + +const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + +const alertsClientParams: jest.Mocked = { + logger: loggingSystemMock.create().get(), + authorization: alertingAuthMock, + auditLogger, + ruleDataService: ruleDataServiceMock.create(), + esClient: esClientMock, +}; + +describe('alerts_route.ts', () => { + beforeEach(() => { + jest.resetAllMocks(); + + alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE); + // @ts-expect-error + alertingAuthMock.getAuthorizationFilter.mockImplementation(async () => + Promise.resolve({ filter: [] }) + ); + // @ts-expect-error + alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => { + const authorizedRuleTypes = new Set(); + authorizedRuleTypes.add({ producer: 'apm' }); + return Promise.resolve({ authorizedRuleTypes }); + }); + + alertingAuthMock.ensureAuthorized.mockImplementation( + // @ts-expect-error + async ({ + ruleTypeId, + consumer, + operation, + entity, + }: { + ruleTypeId: string; + consumer: string; + operation: string; + entity: typeof AlertingAuthorizationEntity.Alert; + }) => { + if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') { + return Promise.resolve(); + } + return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`)); + } + ); + }); + + describe('doSearch(client, sessionEntityId)', () => { + it('should return an empty events array for a non existant entity_id', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse()); + const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); + const body = await doSearch(alertsClient, 'asdf'); + + expect(body.events.length).toBe(0); + }); + + it('returns results for a particular session entity_id', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); + + const body = await doSearch(alertsClient, 'asdf'); + + expect(body.events.length).toBe(mockEvents.length); + }); + }); +}); diff --git a/x-pack/plugins/session_view/server/routes/alerts_route.ts b/x-pack/plugins/session_view/server/routes/alerts_route.ts new file mode 100644 index 0000000000000..3d03cb5cb8214 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/alerts_route.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 { schema } from '@kbn/config-schema'; +import { IRouter } from '../../../../../src/core/server'; +import { + ALERTS_ROUTE, + ALERTS_PER_PAGE, + ENTRY_SESSION_ENTITY_ID_PROPERTY, +} from '../../common/constants'; +import { expandDottedObject } from '../../common/utils/expand_dotted_object'; +import type { AlertsClient, RuleRegistryPluginStartContract } from '../../../rule_registry/server'; + +export const registerAlertsRoute = ( + router: IRouter, + ruleRegistry: RuleRegistryPluginStartContract +) => { + router.get( + { + path: ALERTS_ROUTE, + validate: { + query: schema.object({ + sessionEntityId: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = await ruleRegistry.getRacClientWithRequest(request); + const { sessionEntityId } = request.query; + const body = await doSearch(client, sessionEntityId); + + return response.ok({ body }); + } + ); +}; + +export const doSearch = async (client: AlertsClient, sessionEntityId: string) => { + const indices = await client.getAuthorizedAlertsIndices(['siem']); + + if (!indices) { + return { events: [] }; + } + + const results = await client.find({ + query: { + match: { + [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, + }, + }, + track_total_hits: false, + size: ALERTS_PER_PAGE, + index: indices.join(','), + }); + + const events = results.hits.hits.map((hit: any) => { + // the alert indexes flattens many properties. this util unflattens them as session view expects structured json. + hit._source = expandDottedObject(hit._source); + + return hit; + }); + + return { events }; +}; diff --git a/x-pack/plugins/session_view/server/routes/index.ts b/x-pack/plugins/session_view/server/routes/index.ts index b8cb80dc1d1d4..17efeb5d07a7b 100644 --- a/x-pack/plugins/session_view/server/routes/index.ts +++ b/x-pack/plugins/session_view/server/routes/index.ts @@ -6,11 +6,14 @@ */ import { IRouter } from '../../../../../src/core/server'; import { registerProcessEventsRoute } from './process_events_route'; +import { registerAlertsRoute } from './alerts_route'; import { registerAlertStatusRoute } from './alert_status_route'; import { sessionEntryLeadersRoute } from './session_entry_leaders_route'; +import { RuleRegistryPluginStartContract } from '../../../rule_registry/server'; -export const registerRoutes = (router: IRouter) => { +export const registerRoutes = (router: IRouter, ruleRegistry: RuleRegistryPluginStartContract) => { registerProcessEventsRoute(router); registerAlertStatusRoute(router); sessionEntryLeadersRoute(router); + registerAlertsRoute(router, ruleRegistry); }; diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.ts b/x-pack/plugins/session_view/server/routes/process_events_route.ts index 47e2d917733d5..0dc864c51a07d 100644 --- a/x-pack/plugins/session_view/server/routes/process_events_route.ts +++ b/x-pack/plugins/session_view/server/routes/process_events_route.ts @@ -11,10 +11,8 @@ import { PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE, PROCESS_EVENTS_INDEX, - ALERTS_INDEX, ENTRY_SESSION_ENTITY_ID_PROPERTY, } from '../../common/constants'; -import { expandDottedObject } from '../../common/utils/expand_dotted_object'; export const registerProcessEventsRoute = (router: IRouter) => { router.get( @@ -45,35 +43,25 @@ export const doSearch = async ( forward = true ) => { const search = await client.search({ - // TODO: move alerts into it's own route with it's own pagination. - index: [PROCESS_EVENTS_INDEX, ALERTS_INDEX], - ignore_unavailable: true, + index: [PROCESS_EVENTS_INDEX], body: { query: { match: { [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, }, }, - // This runtime_mappings is a temporary fix, so we are able to Query these ECS fields while they are not available - // TODO: Remove the runtime_mappings once process.entry_leader.entity_id is implemented to ECS - runtime_mappings: { - [ENTRY_SESSION_ENTITY_ID_PROPERTY]: { - type: 'keyword', - }, - }, size: PROCESS_EVENTS_PER_PAGE, - sort: [{ '@timestamp': forward ? 'asc' : 'desc' }], - search_after: cursor ? [cursor] : undefined, + // we first sort by process.start, this allows lifecycle events to load all at once for a given process, and + // avoid issues like where the session leaders 'end' event is loaded at the very end of what could be multiple pages of events + sort: [ + { 'process.start': forward ? 'asc' : 'desc' }, + { '@timestamp': forward ? 'asc' : 'desc' }, + ], + search_after: cursor ? [cursor, cursor] : undefined, }, }); - const events = search.hits.hits.map((hit: any) => { - // TODO: re-eval if this is needed after moving alerts to it's own route. - // the .siem-signals-default index flattens many properties. this util unflattens them. - hit._source = expandDottedObject(hit._source); - - return hit; - }); + const events = search.hits.hits; if (!forward) { events.reverse(); diff --git a/x-pack/plugins/session_view/server/types.ts b/x-pack/plugins/session_view/server/types.ts index 0d1375081ca87..29995077ccfbe 100644 --- a/x-pack/plugins/session_view/server/types.ts +++ b/x-pack/plugins/session_view/server/types.ts @@ -4,8 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { + RuleRegistryPluginSetupContract as RuleRegistryPluginSetup, + RuleRegistryPluginStartContract as RuleRegistryPluginStart, +} from '../../rule_registry/server'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SessionViewSetupPlugins {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SessionViewStartPlugins {} +export interface SessionViewSetupPlugins { + ruleRegistry: RuleRegistryPluginSetup; +} + +export interface SessionViewStartPlugins { + ruleRegistry: RuleRegistryPluginStart; +} diff --git a/x-pack/plugins/session_view/tsconfig.json b/x-pack/plugins/session_view/tsconfig.json index a99e83976a31d..0a21d320dfb29 100644 --- a/x-pack/plugins/session_view/tsconfig.json +++ b/x-pack/plugins/session_view/tsconfig.json @@ -37,6 +37,7 @@ { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, { "path": "../infra/tsconfig.json" }, - { "path": "../../../src/plugins/kibana_utils/tsconfig.json" } + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../rule_registry/tsconfig.json" } ] } diff --git a/x-pack/plugins/task_manager/server/task_events.test.ts b/x-pack/plugins/task_manager/server/task_events.test.ts index 5d72120da725c..607453b7ea92f 100644 --- a/x-pack/plugins/task_manager/server/task_events.test.ts +++ b/x-pack/plugins/task_manager/server/task_events.test.ts @@ -45,7 +45,8 @@ describe('task_events', () => { expect(result.eventLoopBlockMs).toBe(undefined); }); - describe('startTaskTimerWithEventLoopMonitoring', () => { + // FLAKY: https://github.com/elastic/kibana/issues/128441 + describe.skip('startTaskTimerWithEventLoopMonitoring', () => { test('non-blocking', async () => { const stopTaskTimer = startTaskTimerWithEventLoopMonitoring({ monitor: true, diff --git a/x-pack/plugins/timelines/common/types/timeline/index.ts b/x-pack/plugins/timelines/common/types/timeline/index.ts index a6c8ed1b74bff..1e12baf13c2db 100644 --- a/x-pack/plugins/timelines/common/types/timeline/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/index.ts @@ -310,6 +310,8 @@ export type SavedTimelineNote = runtimeTypes.TypeOf; +export type DataViewTitleSchema = TypeOf; export const transformIdParamSchema = schema.object({ transformId: schema.string(), diff --git a/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts b/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts index 05fefc278e350..e12c144b60af6 100644 --- a/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts +++ b/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts @@ -20,7 +20,7 @@ export const deleteTransformsRequestSchema = schema.object({ }) ), deleteDestIndex: schema.maybe(schema.boolean()), - deleteDestIndexPattern: schema.maybe(schema.boolean()), + deleteDestDataView: schema.maybe(schema.boolean()), forceDelete: schema.maybe(schema.boolean()), }); @@ -29,7 +29,7 @@ export type DeleteTransformsRequestSchema = TypeOf { + test('isDataView()', () => { + expect(isDataView(0)).toBe(false); + expect(isDataView('')).toBe(false); + expect(isDataView(null)).toBe(false); + expect(isDataView({})).toBe(false); + expect(isDataView({ attribute: 'value' })).toBe(false); + expect(isDataView({ fields: [], title: 'Data View Title', getComputedFields: () => {} })).toBe( + true + ); + }); +}); diff --git a/x-pack/plugins/transform/common/types/index_pattern.ts b/x-pack/plugins/transform/common/types/data_view.ts similarity index 61% rename from x-pack/plugins/transform/common/types/index_pattern.ts rename to x-pack/plugins/transform/common/types/data_view.ts index 0485de8982e1a..c09b84dea1e4e 100644 --- a/x-pack/plugins/transform/common/types/index_pattern.ts +++ b/x-pack/plugins/transform/common/types/data_view.ts @@ -5,18 +5,18 @@ * 2.0. */ -import type { IndexPattern } from '../../../../../src/plugins/data/common'; +import type { DataView } from '../../../../../src/plugins/data_views/common'; import { isPopulatedObject } from '../shared_imports'; -// Custom minimal type guard for IndexPattern to check against the attributes used in transforms code. -export function isIndexPattern(arg: any): arg is IndexPattern { +// Custom minimal type guard for DataView to check against the attributes used in transforms code. +export function isDataView(arg: any): arg is DataView { return ( isPopulatedObject(arg, ['title', 'fields']) && // `getComputedFields` is inherited, so it's not possible to // check with `hasOwnProperty` which is used by isPopulatedObject() - 'getComputedFields' in (arg as IndexPattern) && - typeof (arg as IndexPattern).getComputedFields === 'function' && + 'getComputedFields' in (arg as DataView) && + typeof (arg as DataView).getComputedFields === 'function' && typeof arg.title === 'string' && Array.isArray(arg.fields) ); diff --git a/x-pack/plugins/transform/common/types/index_pattern.test.ts b/x-pack/plugins/transform/common/types/index_pattern.test.ts deleted file mode 100644 index 57d57473d99de..0000000000000 --- a/x-pack/plugins/transform/common/types/index_pattern.test.ts +++ /dev/null @@ -1,21 +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 { isIndexPattern } from './index_pattern'; - -describe('index_pattern', () => { - test('isIndexPattern()', () => { - expect(isIndexPattern(0)).toBe(false); - expect(isIndexPattern('')).toBe(false); - expect(isIndexPattern(null)).toBe(false); - expect(isIndexPattern({})).toBe(false); - expect(isIndexPattern({ attribute: 'value' })).toBe(false); - expect( - isIndexPattern({ fields: [], title: 'Data View Title', getComputedFields: () => {} }) - ).toBe(true); - }); -}); diff --git a/x-pack/plugins/transform/common/types/transform.ts b/x-pack/plugins/transform/common/types/transform.ts index 92ffc0b99bc3d..a196111bf6678 100644 --- a/x-pack/plugins/transform/common/types/transform.ts +++ b/x-pack/plugins/transform/common/types/transform.ts @@ -13,7 +13,7 @@ import type { PivotAggDict } from './pivot_aggs'; import type { TransformHealthAlertRule } from './alerting'; export type IndexName = string; -export type IndexPattern = string; +export type DataView = string; export type TransformId = string; /** diff --git a/x-pack/plugins/transform/public/app/common/data_grid.ts b/x-pack/plugins/transform/public/app/common/data_grid.ts index 082e73651bb72..43d2b27f13cf9 100644 --- a/x-pack/plugins/transform/public/app/common/data_grid.ts +++ b/x-pack/plugins/transform/public/app/common/data_grid.ts @@ -15,8 +15,8 @@ export const getPivotPreviewDevConsoleStatement = (request: PostTransformsPrevie return `POST _transform/_preview\n${JSON.stringify(request, null, 2)}\n`; }; -export const getIndexDevConsoleStatement = (query: PivotQuery, indexPatternTitle: string) => { - return `GET ${indexPatternTitle}/_search\n${JSON.stringify( +export const getIndexDevConsoleStatement = (query: PivotQuery, dataViewTitle: string) => { + return `GET ${dataViewTitle}/_search\n${JSON.stringify( { query, }, diff --git a/x-pack/plugins/transform/public/app/common/request.test.ts b/x-pack/plugins/transform/public/app/common/request.test.ts index cd34b20cc87a6..f8c5a64099ba2 100644 --- a/x-pack/plugins/transform/public/app/common/request.test.ts +++ b/x-pack/plugins/transform/public/app/common/request.test.ts @@ -80,7 +80,7 @@ describe('Transform: Common', () => { test('getPreviewTransformRequestBody()', () => { const query = getPivotQuery('the-query'); - const request = getPreviewTransformRequestBody('the-index-pattern-title', query, { + const request = getPreviewTransformRequestBody('the-data-view-title', query, { pivot: { aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } }, group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, @@ -93,7 +93,7 @@ describe('Transform: Common', () => { group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-query' } }, }, }); @@ -101,16 +101,12 @@ describe('Transform: Common', () => { test('getPreviewTransformRequestBody() with comma-separated index pattern', () => { const query = getPivotQuery('the-query'); - const request = getPreviewTransformRequestBody( - 'the-index-pattern-title,the-other-title', - query, - { - pivot: { - aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } }, - group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, - }, - } - ); + const request = getPreviewTransformRequestBody('the-data-view-title,the-other-title', query, { + pivot: { + aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } }, + group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, + }, + }); expect(request).toEqual({ pivot: { @@ -118,7 +114,7 @@ describe('Transform: Common', () => { group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, }, source: { - index: ['the-index-pattern-title', 'the-other-title'], + index: ['the-data-view-title', 'the-other-title'], query: { query_string: { default_operator: 'AND', query: 'the-query' } }, }, }); @@ -178,7 +174,7 @@ describe('Transform: Common', () => { test('getPreviewTransformRequestBody() with missing_buckets config', () => { const query = getPivotQuery('the-query'); const request = getPreviewTransformRequestBody( - 'the-index-pattern-title', + 'the-data-view-title', query, getRequestPayload([aggsAvg], [{ ...groupByTerms, ...{ missing_bucket: true } }]) ); @@ -191,7 +187,7 @@ describe('Transform: Common', () => { }, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-query' } }, }, }); @@ -226,7 +222,7 @@ describe('Transform: Common', () => { const transformDetailsState: StepDetailsExposedState = { continuousModeDateField: 'the-continuous-mode-date-field', continuousModeDelay: 'the-continuous-mode-delay', - createIndexPattern: false, + createDataView: false, isContinuousModeEnabled: false, isRetentionPolicyEnabled: false, retentionPolicyDateField: '', @@ -243,7 +239,7 @@ describe('Transform: Common', () => { }; const request = getCreateTransformRequestBody( - 'the-index-pattern-title', + 'the-data-view-title', pivotState, transformDetailsState ); @@ -261,7 +257,7 @@ describe('Transform: Common', () => { docs_per_second: 400, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-search-query' } }, }, }); @@ -305,7 +301,7 @@ describe('Transform: Common', () => { const transformDetailsState: StepDetailsExposedState = { continuousModeDateField: 'the-continuous-mode-date-field', continuousModeDelay: 'the-continuous-mode-delay', - createIndexPattern: false, + createDataView: false, isContinuousModeEnabled: false, isRetentionPolicyEnabled: false, retentionPolicyDateField: '', @@ -322,7 +318,7 @@ describe('Transform: Common', () => { }; const request = getCreateTransformRequestBody( - 'the-index-pattern-title', + 'the-data-view-title', pivotState, transformDetailsState ); @@ -340,7 +336,7 @@ describe('Transform: Common', () => { docs_per_second: 400, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-search-query' } }, runtime_mappings: runtimeMappings, }, diff --git a/x-pack/plugins/transform/public/app/common/request.ts b/x-pack/plugins/transform/public/app/common/request.ts index 36776759eb47a..0f94f82355fd2 100644 --- a/x-pack/plugins/transform/public/app/common/request.ts +++ b/x-pack/plugins/transform/public/app/common/request.ts @@ -8,7 +8,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { HttpFetchError } from '../../../../../../src/core/public'; -import type { IndexPattern } from '../../../../../../src/plugins/data/public'; +import type { DataView } from '../../../../../../src/plugins/data_views/public'; import type { PivotTransformPreviewRequestSchema, @@ -19,7 +19,7 @@ import type { } from '../../../common/api_schemas/transforms'; import { isPopulatedObject } from '../../../common/shared_imports'; import { DateHistogramAgg, HistogramAgg, TermsAgg } from '../../../common/types/pivot_group_by'; -import { isIndexPattern } from '../../../common/types/index_pattern'; +import { isDataView } from '../../../common/types/data_view'; import type { SavedSearchQuery } from '../hooks/use_search_items'; import type { StepDefineExposedState } from '../sections/create_transform/components/step_define'; @@ -78,14 +78,14 @@ export function isDefaultQuery(query: PivotQuery): boolean { } export function getCombinedRuntimeMappings( - indexPattern: IndexPattern | undefined, + dataView: DataView | undefined, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ): StepDefineExposedState['runtimeMappings'] | undefined { let combinedRuntimeMappings = {}; // And runtime field mappings defined by index pattern - if (isIndexPattern(indexPattern)) { - const computedFields = indexPattern.getComputedFields(); + if (isDataView(dataView)) { + const computedFields = dataView.getComputedFields(); if (computedFields?.runtimeFields !== undefined) { const ipRuntimeMappings = computedFields.runtimeFields; if (isPopulatedObject(ipRuntimeMappings)) { @@ -167,12 +167,12 @@ export const getRequestPayload = ( }; export function getPreviewTransformRequestBody( - indexPatternTitle: IndexPattern['title'], + dataViewTitle: DataView['title'], query: PivotQuery, partialRequest?: StepDefineExposedState['previewRequest'] | undefined, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ): PostTransformsPreviewRequestSchema { - const index = indexPatternTitle.split(',').map((name: string) => name.trim()); + const index = dataViewTitle.split(',').map((name: string) => name.trim()); return { source: { @@ -199,12 +199,12 @@ export const getCreateTransformSettingsRequestBody = ( }; export const getCreateTransformRequestBody = ( - indexPatternTitle: IndexPattern['title'], + dataViewTitle: DataView['title'], pivotState: StepDefineExposedState, transformDetailsState: StepDetailsExposedState ): PutTransformsPivotRequestSchema | PutTransformsLatestRequestSchema => ({ ...getPreviewTransformRequestBody( - indexPatternTitle, + dataViewTitle, getPivotQuery(pivotState.searchQuery), pivotState.previewRequest, pivotState.runtimeMappings diff --git a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts index 979a98ececabb..cd46caf931e17 100644 --- a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts @@ -166,7 +166,7 @@ const apiFactory = () => ({ return Promise.resolve([]); }, async getHistogramsForFields( - indexPatternTitle: string, + dataViewTitle: string, fields: FieldHistogramRequestConfig[], query: string | SavedSearchQuery, samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 7119ad2719f5e..65c0d2050a5ed 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -226,14 +226,14 @@ export const useApi = () => { } }, async getHistogramsForFields( - indexPatternTitle: string, + dataViewTitle: string, fields: FieldHistogramRequestConfig[], query: string | SavedSearchQuery, runtimeMappings?: FieldHistogramsRequestSchema['runtimeMappings'], samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE ): Promise { try { - return await http.post(`${API_BASE_PATH}field_histograms/${indexPatternTitle}`, { + return await http.post(`${API_BASE_PATH}field_histograms/${dataViewTitle}`, { body: JSON.stringify({ query, fields, diff --git a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx index ff93f027fc3a4..65a20f2d24ddf 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx @@ -30,24 +30,24 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { const toastNotifications = useToastNotifications(); const [deleteDestIndex, setDeleteDestIndex] = useState(true); - const [deleteIndexPattern, setDeleteIndexPattern] = useState(true); + const [deleteDataView, setDeleteDataView] = useState(true); const [userCanDeleteIndex, setUserCanDeleteIndex] = useState(false); - const [indexPatternExists, setIndexPatternExists] = useState(false); + const [dataViewExists, setDataViewExists] = useState(false); const [userCanDeleteDataView, setUserCanDeleteDataView] = useState(false); const toggleDeleteIndex = useCallback( () => setDeleteDestIndex(!deleteDestIndex), [deleteDestIndex] ); - const toggleDeleteIndexPattern = useCallback( - () => setDeleteIndexPattern(!deleteIndexPattern), - [deleteIndexPattern] + const toggleDeleteDataView = useCallback( + () => setDeleteDataView(!deleteDataView), + [deleteDataView] ); - const checkIndexPatternExists = useCallback( + const checkDataViewExists = useCallback( async (indexName: string) => { try { - if (await indexService.indexPatternExists(savedObjects.client, indexName)) { - setIndexPatternExists(true); + if (await indexService.dataViewExists(savedObjects.client, indexName)) { + setDataViewExists(true); } } catch (e) { const error = extractErrorMessage(e); @@ -77,7 +77,7 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { capabilities.indexPatterns.save === true; setUserCanDeleteDataView(canDeleteDataView); if (canDeleteDataView === false) { - setDeleteIndexPattern(false); + setDeleteDataView(false); } } catch (e) { toastNotifications.addDanger( @@ -100,20 +100,20 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { const destinationIndex = Array.isArray(config.dest.index) ? config.dest.index[0] : config.dest.index; - checkIndexPatternExists(destinationIndex); + checkDataViewExists(destinationIndex); } else { - setIndexPatternExists(true); + setDataViewExists(true); } - }, [checkIndexPatternExists, checkUserIndexPermission, items]); + }, [checkDataViewExists, checkUserIndexPermission, items]); return { userCanDeleteIndex, userCanDeleteDataView, deleteDestIndex, - indexPatternExists, - deleteIndexPattern, + dataViewExists, + deleteDataView, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, }; }; @@ -149,7 +149,7 @@ export const useDeleteTransforms = () => { const successCount: Record = { transformDeleted: 0, destIndexDeleted: 0, - destIndexPatternDeleted: 0, + destDataViewDeleted: 0, }; for (const transformId in results) { // hasOwnProperty check to ensure only properties on object itself, and not its prototypes @@ -179,7 +179,7 @@ export const useDeleteTransforms = () => { ) ); } - if (status.destIndexPatternDeleted?.success) { + if (status.destDataViewDeleted?.success) { toastNotifications.addSuccess( i18n.translate( 'xpack.transform.deleteTransform.deleteAnalyticsWithDataViewSuccessMessage', @@ -238,8 +238,8 @@ export const useDeleteTransforms = () => { }); } - if (status.destIndexPatternDeleted?.error) { - const error = status.destIndexPatternDeleted.error.reason; + if (status.destDataViewDeleted?.error) { + const error = status.destDataViewDeleted.error.reason; toastNotifications.addDanger({ title: i18n.translate( 'xpack.transform.deleteTransform.deleteAnalyticsWithDataViewErrorMessage', @@ -283,12 +283,12 @@ export const useDeleteTransforms = () => { }) ); } - if (successCount.destIndexPatternDeleted > 0) { + if (successCount.destDataViewDeleted > 0) { toastNotifications.addSuccess( i18n.translate('xpack.transform.transformList.bulkDeleteDestDataViewSuccessMessage', { defaultMessage: 'Successfully deleted {count} destination data {count, plural, one {view} other {views}}.', - values: { count: successCount.destIndexPatternDeleted }, + values: { count: successCount.destDataViewDeleted }, }) ); } diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx b/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx index 74d5167c12697..d74c11cbaf607 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx @@ -46,7 +46,7 @@ const runtimeMappings = { }; describe('Transform: useIndexData()', () => { - test('indexPattern set triggers loading', async () => { + test('dataView set triggers loading', async () => { const mlShared = await getMlSharedImports(); const wrapper: FC = ({ children }) => ( @@ -61,7 +61,7 @@ describe('Transform: useIndexData()', () => { id: 'the-id', title: 'the-title', fields: [], - } as unknown as SearchItems['indexPattern'], + } as unknown as SearchItems['dataView'], query, runtimeMappings ), @@ -81,10 +81,10 @@ describe('Transform: useIndexData()', () => { describe('Transform: with useIndexData()', () => { test('Minimal initialization, no cross cluster search warning.', async () => { // Arrange - const indexPattern = { - title: 'the-index-pattern-title', + const dataView = { + title: 'the-data-view-title', fields: [] as any[], - } as SearchItems['indexPattern']; + } as SearchItems['dataView']; const mlSharedImports = await getMlSharedImports(); @@ -93,7 +93,7 @@ describe('Transform: with useIndexData()', () => { ml: { DataGrid }, } = useAppDependencies(); const props = { - ...useIndexData(indexPattern, { match_all: {} }, runtimeMappings), + ...useIndexData(dataView, { match_all: {} }, runtimeMappings), copyToClipboard: 'the-copy-to-clipboard-code', copyToClipboardDescription: 'the-copy-to-clipboard-description', dataTestSubj: 'the-data-test-subj', @@ -124,10 +124,10 @@ describe('Transform: with useIndexData()', () => { test('Cross-cluster search warning', async () => { // Arrange - const indexPattern = { + const dataView = { title: 'remote:the-index-pattern-title', fields: [] as any[], - } as SearchItems['indexPattern']; + } as SearchItems['dataView']; const mlSharedImports = await getMlSharedImports(); @@ -136,7 +136,7 @@ describe('Transform: with useIndexData()', () => { ml: { DataGrid }, } = useAppDependencies(); const props = { - ...useIndexData(indexPattern, { match_all: {} }, runtimeMappings), + ...useIndexData(dataView, { match_all: {} }, runtimeMappings), copyToClipboard: 'the-copy-to-clipboard-code', copyToClipboardDescription: 'the-copy-to-clipboard-description', dataTestSubj: 'the-data-test-subj', diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index 1d73413b3e386..678ec6d291ceb 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -31,7 +31,7 @@ import type { StepDefineExposedState } from '../sections/create_transform/compon import { isRuntimeMappings } from '../../../common/shared_imports'; export const useIndexData = ( - indexPattern: SearchItems['indexPattern'], + dataView: SearchItems['dataView'], query: PivotQuery, combinedRuntimeMappings?: StepDefineExposedState['runtimeMappings'] ): UseIndexDataReturnType => { @@ -51,7 +51,7 @@ export const useIndexData = ( }, } = useAppDependencies(); - const [indexPatternFields, setIndexPatternFields] = useState(); + const [dataViewFields, setDataViewFields] = useState(); // Fetch 500 random documents to determine populated fields. // This is a workaround to avoid passing potentially thousands of unpopulated fields @@ -62,7 +62,7 @@ export const useIndexData = ( setStatus(INDEX_STATUS.LOADING); const esSearchRequest = { - index: indexPattern.title, + index: dataView.title, body: { fields: ['*'], _source: false, @@ -84,21 +84,21 @@ export const useIndexData = ( return; } - const isCrossClusterSearch = indexPattern.title.includes(':'); + const isCrossClusterSearch = dataView.title.includes(':'); const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); // Get all field names for each returned doc and flatten it // to a list of unique field names used across all docs. - const allKibanaIndexPatternFields = getFieldsFromKibanaIndexPattern(indexPattern); + const allDataViewFields = getFieldsFromKibanaIndexPattern(dataView); const populatedFields = [...new Set(docs.map(Object.keys).flat(1))] - .filter((d) => allKibanaIndexPatternFields.includes(d)) + .filter((d) => allDataViewFields.includes(d)) .sort(); setCcsWarning(isCrossClusterSearch && isMissingFields); setStatus(INDEX_STATUS.LOADED); - setIndexPatternFields(populatedFields); + setDataViewFields(populatedFields); }; useEffect(() => { @@ -107,7 +107,7 @@ export const useIndexData = ( }, []); const columns: EuiDataGridColumn[] = useMemo(() => { - if (typeof indexPatternFields === 'undefined') { + if (typeof dataViewFields === 'undefined') { return []; } @@ -124,8 +124,8 @@ export const useIndexData = ( } // Combine the runtime field that are defined from API field - indexPatternFields.forEach((id) => { - const field = indexPattern.fields.getByName(id); + dataViewFields.forEach((id) => { + const field = dataView.fields.getByName(id); if (!field?.runtimeField) { const schema = getDataGridSchemaFromKibanaFieldType(field); result.push({ id, schema }); @@ -134,8 +134,8 @@ export const useIndexData = ( return result.sort((a, b) => a.id.localeCompare(b.id)); }, [ - indexPatternFields, - indexPattern.fields, + dataViewFields, + dataView.fields, combinedRuntimeMappings, getDataGridSchemaFromESFieldType, getDataGridSchemaFromKibanaFieldType, @@ -176,7 +176,7 @@ export const useIndexData = ( }, {} as EsSorting); const esSearchRequest = { - index: indexPattern.title, + index: dataView.title, body: { fields: ['*'], _source: false, @@ -198,7 +198,7 @@ export const useIndexData = ( return; } - const isCrossClusterSearch = indexPattern.title.includes(':'); + const isCrossClusterSearch = dataView.title.includes(':'); const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); @@ -215,16 +215,16 @@ export const useIndexData = ( }; const fetchColumnChartsData = async function () { - const allIndexPatternFieldNames = new Set(indexPattern.fields.map((f) => f.name)); + const allDataViewFieldNames = new Set(dataView.fields.map((f) => f.name)); const columnChartsData = await api.getHistogramsForFields( - indexPattern.title, + dataView.title, columns .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) .map((cT) => { // If a column field name has a corresponding keyword field, // fetch the keyword field instead to be able to do aggregations. const fieldName = cT.id; - return hasKeywordDuplicate(fieldName, allIndexPatternFieldNames) + return hasKeywordDuplicate(fieldName, allDataViewFieldNames) ? { fieldName: `${fieldName}.keyword`, type: getFieldType(undefined), @@ -247,7 +247,7 @@ export const useIndexData = ( // revert field names with `.keyword` used to do aggregations to their original column name columnChartsData.map((d) => ({ ...d, - ...(isKeywordDuplicate(d.id, allIndexPatternFieldNames) + ...(isKeywordDuplicate(d.id, allDataViewFieldNames) ? { id: removeKeywordPostfix(d.id) } : {}), })) @@ -259,15 +259,9 @@ export const useIndexData = ( // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - indexPattern.title, + dataView.title, // eslint-disable-next-line react-hooks/exhaustive-deps - JSON.stringify([ - query, - pagination, - sortingColumns, - indexPatternFields, - combinedRuntimeMappings, - ]), + JSON.stringify([query, pagination, sortingColumns, dataViewFields, combinedRuntimeMappings]), ]); useEffect(() => { @@ -278,12 +272,12 @@ export const useIndexData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [ chartsVisible, - indexPattern.title, + dataView.title, // eslint-disable-next-line react-hooks/exhaustive-deps JSON.stringify([query, dataGrid.visibleColumns, combinedRuntimeMappings]), ]); - const renderCellValue = useRenderCellValue(indexPattern, pagination, tableItems); + const renderCellValue = useRenderCellValue(dataView, pagination, tableItems); return { ...dataGrid, diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts index 01cb39ac87fa8..d30237abcdb3f 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts @@ -96,7 +96,7 @@ export function getCombinedProperties( } export const usePivotData = ( - indexPatternTitle: SearchItems['indexPattern']['title'], + dataViewTitle: SearchItems['dataView']['title'], query: PivotQuery, validationStatus: StepDefineExposedState['validationStatus'], requestPayload: StepDefineExposedState['previewRequest'], @@ -165,7 +165,7 @@ export const usePivotData = ( setStatus(INDEX_STATUS.LOADING); const previewRequest = getPreviewTransformRequestBody( - indexPatternTitle, + dataViewTitle, query, requestPayload, combinedRuntimeMappings @@ -233,7 +233,7 @@ export const usePivotData = ( getPreviewData(); // custom comparison /* eslint-disable react-hooks/exhaustive-deps */ - }, [indexPatternTitle, JSON.stringify([requestPayload, query, combinedRuntimeMappings])]); + }, [dataViewTitle, JSON.stringify([requestPayload, query, combinedRuntimeMappings])]); if (sortingColumns.length > 0) { const sortingColumnsWithTypes = sortingColumns.map((c) => ({ diff --git a/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts index 19ff063d11acf..910960cb24eea 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts @@ -7,45 +7,45 @@ import { buildEsQuery } from '@kbn/es-query'; import { SavedObjectsClientContract, SimpleSavedObject, IUiSettingsClient } from 'src/core/public'; +import { getEsQueryConfig } from '../../../../../../../src/plugins/data/public'; import { - IndexPattern, - getEsQueryConfig, - IndexPatternsContract, - IndexPatternAttributes, -} from '../../../../../../../src/plugins/data/public'; + DataView, + DataViewAttributes, + DataViewsContract, +} from '../../../../../../../src/plugins/data_views/public'; import { matchAllQuery } from '../../common'; -import { isIndexPattern } from '../../../../common/types/index_pattern'; +import { isDataView } from '../../../../common/types/data_view'; export type SavedSearchQuery = object; -type IndexPatternId = string; +type DataViewId = string; -let indexPatternCache: Array>> = []; -let fullIndexPatterns; -let currentIndexPattern = null; +let dataViewCache: Array>> = []; +let fullDataViews; +let currentDataView = null; -export let refreshIndexPatterns: () => Promise; +export let refreshDataViews: () => Promise; -export function loadIndexPatterns( +export function loadDataViews( savedObjectsClient: SavedObjectsClientContract, - indexPatterns: IndexPatternsContract + dataViews: DataViewsContract ) { - fullIndexPatterns = indexPatterns; + fullDataViews = dataViews; return savedObjectsClient - .find({ + .find({ type: 'index-pattern', fields: ['id', 'title', 'type', 'fields'], perPage: 10000, }) .then((response) => { - indexPatternCache = response.savedObjects; + dataViewCache = response.savedObjects; - if (refreshIndexPatterns === null) { - refreshIndexPatterns = () => { + if (refreshDataViews === null) { + refreshDataViews = () => { return new Promise((resolve, reject) => { - loadIndexPatterns(savedObjectsClient, indexPatterns) + loadDataViews(savedObjectsClient, dataViews) .then((resp) => { resolve(resp); }) @@ -56,27 +56,24 @@ export function loadIndexPatterns( }; } - return indexPatternCache; + return dataViewCache; }); } -export function getIndexPatternIdByTitle(indexPatternTitle: string): string | undefined { - return indexPatternCache.find((d) => d?.attributes?.title === indexPatternTitle)?.id; +export function getDataViewIdByTitle(dataViewTitle: string): string | undefined { + return dataViewCache.find((d) => d?.attributes?.title === dataViewTitle)?.id; } type CombinedQuery = Record<'bool', any> | object; -export function loadCurrentIndexPattern( - indexPatterns: IndexPatternsContract, - indexPatternId: IndexPatternId -) { - fullIndexPatterns = indexPatterns; - currentIndexPattern = fullIndexPatterns.get(indexPatternId); - return currentIndexPattern; +export function loadCurrentDataView(dataViews: DataViewsContract, dataViewId: DataViewId) { + fullDataViews = dataViews; + currentDataView = fullDataViews.get(dataViewId); + return currentDataView; } export interface SearchItems { - indexPattern: IndexPattern; + dataView: DataView; savedSearch: any; query: any; combinedQuery: CombinedQuery; @@ -84,7 +81,7 @@ export interface SearchItems { // Helper for creating the items used for searching and job creation. export function createSearchItems( - indexPattern: IndexPattern | undefined, + dataView: DataView | undefined, savedSearch: any, config: IUiSettingsClient ): SearchItems { @@ -103,9 +100,9 @@ export function createSearchItems( }, }; - if (!isIndexPattern(indexPattern) && savedSearch !== null && savedSearch.id !== undefined) { + if (!isDataView(dataView) && savedSearch !== null && savedSearch.id !== undefined) { const searchSource = savedSearch.searchSource; - indexPattern = searchSource.getField('index') as IndexPattern; + dataView = searchSource.getField('index') as DataView; query = searchSource.getField('query'); const fs = searchSource.getField('filter'); @@ -113,15 +110,15 @@ export function createSearchItems( const filters = fs.length ? fs : []; const esQueryConfigs = getEsQueryConfig(config); - combinedQuery = buildEsQuery(indexPattern, [query], filters, esQueryConfigs); + combinedQuery = buildEsQuery(dataView, [query], filters, esQueryConfigs); } - if (!isIndexPattern(indexPattern)) { + if (!isDataView(dataView)) { throw new Error('Data view is not defined.'); } return { - indexPattern, + dataView, savedSearch, query, combinedQuery, diff --git a/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts index 754cc24b65fec..76fdc77c523e4 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts @@ -9,7 +9,7 @@ import { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { isIndexPattern } from '../../../../common/types/index_pattern'; +import { isDataView } from '../../../../common/types/data_view'; import { getSavedSearch, getSavedSearchUrlConflictMessage } from '../../../shared_imports'; @@ -17,9 +17,9 @@ import { useAppDependencies } from '../../app_dependencies'; import { createSearchItems, - getIndexPatternIdByTitle, - loadCurrentIndexPattern, - loadIndexPatterns, + getDataViewIdByTitle, + loadCurrentDataView, + loadDataViews, SearchItems, } from './common'; @@ -28,22 +28,22 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { const [error, setError] = useState(); const appDeps = useAppDependencies(); - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const uiSettings = appDeps.uiSettings; const savedObjectsClient = appDeps.savedObjects.client; const [searchItems, setSearchItems] = useState(undefined); async function fetchSavedObject(id: string) { - await loadIndexPatterns(savedObjectsClient, indexPatterns); + await loadDataViews(savedObjectsClient, dataViews); - let fetchedIndexPattern; + let fetchedDataView; let fetchedSavedSearch; try { - fetchedIndexPattern = await loadCurrentIndexPattern(indexPatterns, id); + fetchedDataView = await loadCurrentDataView(dataViews, id); } catch (e) { - // Just let fetchedIndexPattern stay undefined in case it doesn't exist. + // Just let fetchedDataView stay undefined in case it doesn't exist. } try { @@ -61,7 +61,7 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { // Just let fetchedSavedSearch stay undefined in case it doesn't exist. } - if (!isIndexPattern(fetchedIndexPattern) && fetchedSavedSearch === undefined) { + if (!isDataView(fetchedDataView) && fetchedSavedSearch === undefined) { setError( i18n.translate('xpack.transform.searchItems.errorInitializationTitle', { defaultMessage: `An error occurred initializing the Kibana data view or saved search.`, @@ -70,7 +70,7 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { return; } - setSearchItems(createSearchItems(fetchedIndexPattern, fetchedSavedSearch, uiSettings)); + setSearchItems(createSearchItems(fetchedDataView, fetchedSavedSearch, uiSettings)); setError(undefined); } @@ -84,8 +84,8 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { return { error, - getIndexPatternIdByTitle, - loadIndexPatterns, + getDataViewIdByTitle, + loadDataViews, searchItems, setSavedObjectId, }; diff --git a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index c84f7cb97c959..dceb585c5c190 100644 --- a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -36,7 +36,7 @@ import { overrideTransformForCloning } from '../../common/transform'; type Props = RouteComponentProps<{ transformId: string }>; export const CloneTransformSection: FC = ({ match, location }) => { - const { indexPatternId }: Record = parse(location.search, { + const { dataViewId }: Record = parse(location.search, { sort: false, }); // Set breadcrumb and page title @@ -73,7 +73,7 @@ export const CloneTransformSection: FC = ({ match, location }) => { } try { - if (indexPatternId === undefined) { + if (dataViewId === undefined) { throw new Error( i18n.translate('xpack.transform.clone.fetchErrorPromptText', { defaultMessage: 'Could not fetch the Kibana data view ID.', @@ -81,7 +81,7 @@ export const CloneTransformSection: FC = ({ match, location }) => { ); } - setSavedObjectId(indexPatternId); + setSavedObjectId(dataViewId); setTransformConfig(overrideTransformForCloning(transformConfigs.transforms[0])); setErrorMessage(undefined); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx index 5006b898f3bb3..b20909ec9e128 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx @@ -18,10 +18,10 @@ import { SearchItems } from '../../../../hooks/use_search_items'; import { StepDefineFormHook, QUERY_LANGUAGE_KUERY } from '../step_define'; interface SourceSearchBarProps { - indexPattern: SearchItems['indexPattern']; + dataView: SearchItems['dataView']; searchBar: StepDefineFormHook['searchBar']; } -export const SourceSearchBar: FC = ({ indexPattern, searchBar }) => { +export const SourceSearchBar: FC = ({ dataView, searchBar }) => { const { actions: { searchChangeHandler, searchSubmitHandler, setErrorMessage }, state: { errorMessage, searchInput }, @@ -35,7 +35,7 @@ export const SourceSearchBar: FC = ({ indexPattern, search ', () => { test('Minimal initialization', () => { // Arrange const props: StepCreateFormProps = { - createIndexPattern: false, + createDataView: false, transformId: 'the-transform-id', transformConfig: { dest: { @@ -31,7 +31,7 @@ describe('Transform: ', () => { index: 'the-source-index', }, }, - overrides: { created: false, started: false, indexPatternId: undefined }, + overrides: { created: false, started: false, dataViewId: undefined }, onChange() {}, }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index 42b50e6ef4c1f..bac7754842510 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -56,19 +56,19 @@ import { TransformAlertFlyout } from '../../../../../alerting/transform_alerting export interface StepDetailsExposedState { created: boolean; started: boolean; - indexPatternId: string | undefined; + dataViewId: string | undefined; } export function getDefaultStepCreateState(): StepDetailsExposedState { return { created: false, started: false, - indexPatternId: undefined, + dataViewId: undefined, }; } export interface StepCreateFormProps { - createIndexPattern: boolean; + createDataView: boolean; transformId: string; transformConfig: PutTransformsPivotRequestSchema | PutTransformsLatestRequestSchema; overrides: StepDetailsExposedState; @@ -77,7 +77,7 @@ export interface StepCreateFormProps { } export const StepCreateForm: FC = React.memo( - ({ createIndexPattern, transformConfig, transformId, onChange, overrides, timeFieldName }) => { + ({ createDataView, transformConfig, transformId, onChange, overrides, timeFieldName }) => { const defaults = { ...getDefaultStepCreateState(), ...overrides }; const [redirectToTransformManagement, setRedirectToTransformManagement] = useState(false); @@ -86,7 +86,7 @@ export const StepCreateForm: FC = React.memo( const [created, setCreated] = useState(defaults.created); const [started, setStarted] = useState(defaults.started); const [alertFlyoutVisible, setAlertFlyoutVisible] = useState(false); - const [indexPatternId, setIndexPatternId] = useState(defaults.indexPatternId); + const [dataViewId, setDataViewId] = useState(defaults.dataViewId); const [progressPercentComplete, setProgressPercentComplete] = useState( undefined ); @@ -94,14 +94,14 @@ export const StepCreateForm: FC = React.memo( const deps = useAppDependencies(); const { share } = deps; - const indexPatterns = deps.data.indexPatterns; + const dataViews = deps.data.dataViews; const toastNotifications = useToastNotifications(); const isDiscoverAvailable = deps.application.capabilities.discover?.show ?? false; useEffect(() => { let unmounted = false; - onChange({ created, started, indexPatternId }); + onChange({ created, started, dataViewId }); const getDiscoverUrl = async (): Promise => { const locator = share.url.locators.get(DISCOVER_APP_LOCATOR); @@ -109,7 +109,7 @@ export const StepCreateForm: FC = React.memo( if (!locator) return; const discoverUrl = await locator.getUrl({ - indexPatternId, + indexPatternId: dataViewId, }); if (!unmounted) { @@ -117,7 +117,7 @@ export const StepCreateForm: FC = React.memo( } }; - if (started === true && indexPatternId !== undefined && isDiscoverAvailable) { + if (started === true && dataViewId !== undefined && isDiscoverAvailable) { getDiscoverUrl(); } @@ -126,7 +126,7 @@ export const StepCreateForm: FC = React.memo( }; // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps - }, [created, started, indexPatternId]); + }, [created, started, dataViewId]); const { overlays, theme } = useAppDependencies(); const api = useApi(); @@ -174,8 +174,8 @@ export const StepCreateForm: FC = React.memo( setCreated(true); setLoading(false); - if (createIndexPattern) { - createKibanaIndexPattern(); + if (createDataView) { + createKibanaDataView(); } return true; @@ -228,7 +228,7 @@ export const StepCreateForm: FC = React.memo( } } - const createKibanaIndexPattern = async () => { + const createKibanaDataView = async () => { setLoading(true); const dataViewName = transformConfig.dest.index; const runtimeMappings = transformConfig.source.runtime_mappings as Record< @@ -237,7 +237,7 @@ export const StepCreateForm: FC = React.memo( >; try { - const newIndexPattern = await indexPatterns.createAndSave( + const newDataView = await dataViews.createAndSave( { title: dataViewName, timeFieldName, @@ -256,7 +256,7 @@ export const StepCreateForm: FC = React.memo( }) ); - setIndexPatternId(newIndexPattern.id); + setDataViewId(newDataView.id); setLoading(false); return true; } catch (e) { @@ -529,7 +529,7 @@ export const StepCreateForm: FC = React.memo( data-test-subj="transformWizardCardManagement" /> - {started === true && createIndexPattern === true && indexPatternId === undefined && ( + {started === true && createDataView === true && dataViewId === undefined && ( diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts index 497f37036725c..e0c8b30a93998 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts @@ -35,11 +35,11 @@ import { getCombinedRuntimeMappings } from '../../../../../common/request'; export function applyTransformConfigToDefineState( state: StepDefineExposedState, transformConfig?: TransformBaseConfig, - indexPattern?: StepDefineFormProps['searchItems']['indexPattern'] + dataView?: StepDefineFormProps['searchItems']['dataView'] ): StepDefineExposedState { // apply runtime fields from both the index pattern and inline configurations state.runtimeMappings = getCombinedRuntimeMappings( - indexPattern, + dataView, transformConfig?.source?.runtime_mappings ); @@ -88,12 +88,12 @@ export function applyTransformConfigToDefineState( state.latestConfig = { unique_key: transformConfig.latest.unique_key.map((v) => ({ value: v, - label: indexPattern ? indexPattern.fields.find((f) => f.name === v)?.displayName ?? v : v, + label: dataView ? dataView.fields.find((f) => f.name === v)?.displayName ?? v : v, })), sort: { value: transformConfig.latest.sort, - label: indexPattern - ? indexPattern.fields.find((f) => f.name === transformConfig.latest.sort)?.displayName ?? + label: dataView + ? dataView.fields.find((f) => f.name === transformConfig.latest.sort)?.displayName ?? transformConfig.latest.sort : transformConfig.latest.sort, }, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts index 9b8dcc1a623e3..61081e7858b27 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts @@ -6,18 +6,18 @@ */ import { getPivotDropdownOptions } from '../common'; -import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../../src/plugins/data_views/public'; import { FilterAggForm } from './filter_agg/components'; import type { RuntimeField } from '../../../../../../../../../../src/plugins/data/common'; describe('Transform: Define Pivot Common', () => { test('getPivotDropdownOptions()', () => { - // The field name includes the characters []> as well as a leading and ending space charcter + // The field name includes the characters []> as well as a leading and ending space character // which cannot be used for aggregation names. The test results verifies that the characters // should still be present in field and dropDownName values, but should be stripped for aggName values. - const indexPattern = { - id: 'the-index-pattern-id', - title: 'the-index-pattern-title', + const dataView = { + id: 'the-data-view-id', + title: 'the-data-view-title', fields: [ { name: ' the-f[i]e>ld ', @@ -27,9 +27,9 @@ describe('Transform: Define Pivot Common', () => { searchable: true, }, ], - } as IndexPattern; + } as DataView; - const options = getPivotDropdownOptions(indexPattern); + const options = getPivotDropdownOptions(dataView); expect(options).toMatchObject({ aggOptions: [ @@ -120,7 +120,7 @@ describe('Transform: Define Pivot Common', () => { }, } as RuntimeField, }; - const optionsWithRuntimeFields = getPivotDropdownOptions(indexPattern, runtimeMappings); + const optionsWithRuntimeFields = getPivotDropdownOptions(dataView, runtimeMappings); expect(optionsWithRuntimeFields).toMatchObject({ aggOptions: [ { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx index 8c3c649749c2f..745cd81908ac8 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx @@ -14,7 +14,7 @@ import { KBN_FIELD_TYPES, RuntimeField, } from '../../../../../../../../../../../../src/plugins/data/common'; -import { IndexPattern } from '../../../../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../../../../src/plugins/data_views/public'; import { FilterTermForm } from './filter_term_form'; describe('FilterAggForm', () => { @@ -27,7 +27,7 @@ describe('FilterAggForm', () => { } as RuntimeField, }; - const indexPattern = { + const dataView = { fields: { getByName: jest.fn((fieldName: string) => { if (fieldName === 'test_text_field') { @@ -42,14 +42,14 @@ describe('FilterAggForm', () => { } }), }, - } as unknown as IndexPattern; + } as unknown as DataView; test('should render only select dropdown on empty configuration', async () => { const onChange = jest.fn(); const { getByLabelText, findByTestId, container } = render( - + @@ -74,7 +74,7 @@ describe('FilterAggForm', () => { const { findByTestId } = render( - + @@ -102,7 +102,7 @@ describe('FilterAggForm', () => { const { rerender, findByTestId } = render( - + @@ -111,7 +111,7 @@ describe('FilterAggForm', () => { // re-render the same component with different props rerender( - + @@ -139,7 +139,7 @@ describe('FilterAggForm', () => { const { findByTestId, container } = render( - + { - const { indexPattern, runtimeMappings } = useContext(CreateTransformWizardContext); + const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext); const filterAggsOptions = useMemo( - () => getSupportedFilterAggs(selectedField, indexPattern!, runtimeMappings), - [indexPattern, selectedField, runtimeMappings] + () => getSupportedFilterAggs(selectedField, dataView!, runtimeMappings), + [dataView, selectedField, runtimeMappings] ); useUpdateEffect(() => { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx index 2d24d07fd7019..11f9dadbb359c 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx @@ -30,7 +30,7 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm selectedField, }) => { const api = useApi(); - const { indexPattern, runtimeMappings } = useContext(CreateTransformWizardContext); + const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext); const toastNotifications = useToastNotifications(); const [options, setOptions] = useState([]); @@ -40,7 +40,7 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm const fetchOptions = useCallback( debounce(async (searchValue: string) => { const esSearchRequest = { - index: indexPattern!.title, + index: dataView!.title, body: { ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), query: { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts index b17f30d115f4a..5c4ff5a53f724 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts @@ -8,9 +8,9 @@ import { EuiComboBoxOptionOption } from '@elastic/eui'; import { ES_FIELD_TYPES, - IndexPattern, KBN_FIELD_TYPES, } from '../../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../../src/plugins/data_views/public'; import { getNestedProperty } from '../../../../../../../common/utils/object_utils'; import { removeKeywordPostfix } from '../../../../../../../common/utils/field_utils'; @@ -58,7 +58,7 @@ export function getKibanaFieldTypeFromEsType(type: string): KBN_FIELD_TYPES { } export function getPivotDropdownOptions( - indexPattern: IndexPattern, + dataView: DataView, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ) { // The available group by options @@ -70,7 +70,7 @@ export function getPivotDropdownOptions( const aggOptionsData: PivotAggsConfigWithUiSupportDict = {}; const ignoreFieldNames = ['_id', '_index', '_type']; - const indexPatternFields = indexPattern.fields + const dataViewFields = dataView.fields .filter( (field) => field.aggregatable === true && @@ -93,7 +93,7 @@ export function getPivotDropdownOptions( const sortByLabel = (a: Field, b: Field) => a.name.localeCompare(b.name); - const combinedFields = [...indexPatternFields, ...runtimeFields].sort(sortByLabel); + const combinedFields = [...dataViewFields, ...runtimeFields].sort(sortByLabel); combinedFields.forEach((field) => { const rawFieldName = field.name; const displayFieldName = removeKeywordPostfix(rawFieldName); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts index d6473abb04702..46d5d1b562a84 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts @@ -30,18 +30,18 @@ export const latestConfigMapper = { /** * Provides available options for unique_key and sort fields - * @param indexPattern + * @param dataView * @param aggConfigs * @param runtimeMappings */ function getOptions( - indexPattern: StepDefineFormProps['searchItems']['indexPattern'], + dataView: StepDefineFormProps['searchItems']['dataView'], aggConfigs: AggConfigs, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ) { const aggConfig = aggConfigs.aggs[0]; const param = aggConfig.type.params.find((p) => p.type === 'field'); - const filteredIndexPatternFields = param + const filteredDataViewFields = param ? (param as unknown as FieldParamType) .getAvailableFields(aggConfig) // runtimeMappings may already include runtime fields defined by the data view @@ -54,7 +54,7 @@ function getOptions( ? Object.keys(runtimeMappings).map((k) => ({ label: k, value: k })) : []; - const uniqueKeyOptions: Array> = filteredIndexPatternFields + const uniqueKeyOptions: Array> = filteredDataViewFields .filter((v) => !ignoreFieldNames.has(v.name)) .map((v) => ({ label: v.displayName, @@ -70,7 +70,7 @@ function getOptions( })) : []; - const indexPatternFieldsSortOptions: Array> = indexPattern.fields + const dataViewFieldsSortOptions: Array> = dataView.fields // The backend API for `latest` allows all field types for sort but the UI will be limited to `date`. .filter((v) => !ignoreFieldNames.has(v.name) && v.sortable && v.type === 'date') .map((v) => ({ @@ -83,9 +83,7 @@ function getOptions( return { uniqueKeyOptions: [...uniqueKeyOptions, ...runtimeFieldsOptions].sort(sortByLabel), - sortFieldOptions: [...indexPatternFieldsSortOptions, ...runtimeFieldsSortOptions].sort( - sortByLabel - ), + sortFieldOptions: [...dataViewFieldsSortOptions, ...runtimeFieldsSortOptions].sort(sortByLabel), }; } @@ -112,7 +110,7 @@ export function validateLatestConfig(config?: LatestFunctionConfig) { export function useLatestFunctionConfig( defaults: StepDefineExposedState['latestConfig'], - indexPattern: StepDefineFormProps['searchItems']['indexPattern'], + dataView: StepDefineFormProps['searchItems']['dataView'], runtimeMappings: StepDefineExposedState['runtimeMappings'] ): { config: LatestFunctionConfigUI; @@ -130,9 +128,9 @@ export function useLatestFunctionConfig( const { data } = useAppDependencies(); const { uniqueKeyOptions, sortFieldOptions } = useMemo(() => { - const aggConfigs = data.search.aggs.createAggConfigs(indexPattern, [{ type: 'terms' }]); - return getOptions(indexPattern, aggConfigs, runtimeMappings); - }, [indexPattern, data.search.aggs, runtimeMappings]); + const aggConfigs = data.search.aggs.createAggConfigs(dataView, [{ type: 'terms' }]); + return getOptions(dataView, aggConfigs, runtimeMappings); + }, [dataView, data.search.aggs, runtimeMappings]); const updateLatestFunctionConfig = useCallback( (update) => diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts index 2415f04c220a6..c16270a6a2dca 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts @@ -100,13 +100,13 @@ function getRootAggregation(item: PivotAggsConfig) { export const usePivotConfig = ( defaults: StepDefineExposedState, - indexPattern: StepDefineFormProps['searchItems']['indexPattern'] + dataView: StepDefineFormProps['searchItems']['dataView'] ) => { const toastNotifications = useToastNotifications(); const { aggOptions, aggOptionsData, groupByOptions, groupByOptionsData, fields } = useMemo( - () => getPivotDropdownOptions(indexPattern, defaults.runtimeMappings), - [defaults.runtimeMappings, indexPattern] + () => getPivotDropdownOptions(dataView, defaults.runtimeMappings), + [defaults.runtimeMappings, dataView] ); // The list of selected aggregations diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts index be6104d393d3f..b8c818720f0a9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts @@ -24,7 +24,7 @@ import { StepDefineFormProps } from '../step_define_form'; export const useSearchBar = ( defaults: StepDefineExposedState, - indexPattern: StepDefineFormProps['searchItems']['indexPattern'] + dataView: StepDefineFormProps['searchItems']['dataView'] ) => { // The internal state of the input query bar updated on every key stroke. const [searchInput, setSearchInput] = useState({ @@ -53,7 +53,7 @@ export const useSearchBar = ( switch (query.language) { case QUERY_LANGUAGE_KUERY: setSearchQuery( - toElasticsearchQuery(fromKueryExpression(query.query as string), indexPattern) + toElasticsearchQuery(fromKueryExpression(query.query as string), dataView) ); return; case QUERY_LANGUAGE_LUCENE: diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts index b56df5e395c88..f4c396808e294 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts @@ -25,21 +25,21 @@ export type StepDefineFormHook = ReturnType; export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefineFormProps) => { const defaults = { ...getDefaultStepDefineState(searchItems), ...overrides }; - const { indexPattern } = searchItems; + const { dataView } = searchItems; const [transformFunction, setTransformFunction] = useState(defaults.transformFunction); - const searchBar = useSearchBar(defaults, indexPattern); - const pivotConfig = usePivotConfig(defaults, indexPattern); + const searchBar = useSearchBar(defaults, dataView); + const pivotConfig = usePivotConfig(defaults, dataView); const latestFunctionConfig = useLatestFunctionConfig( defaults.latestConfig, - indexPattern, + dataView, defaults?.runtimeMappings ); const previewRequest = getPreviewTransformRequestBody( - indexPattern.title, + dataView.title, searchBar.state.pivotQuery, pivotConfig.state.requestPayload, defaults?.runtimeMappings @@ -58,7 +58,7 @@ export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefi const runtimeMappings = runtimeMappingsEditor.state.runtimeMappings; if (!advancedSourceEditor.state.isAdvancedSourceEditorEnabled) { const previewRequestUpdate = getPreviewTransformRequestBody( - indexPattern.title, + dataView.title, searchBar.state.pivotQuery, pivotConfig.state.requestPayload, runtimeMappings diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx index 6e80b6162048e..054deb23eac50 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx @@ -57,10 +57,10 @@ describe('Transform: ', () => { const mlSharedImports = await getMlSharedImports(); const searchItems = { - indexPattern: { - title: 'the-index-pattern-title', + dataView: { + title: 'the-data-view-title', fields: [] as any[], - } as SearchItems['indexPattern'], + } as SearchItems['dataView'], }; // mock services for QueryStringInput @@ -84,7 +84,7 @@ describe('Transform: ', () => { // Act // Assert expect(getByText('Data view')).toBeInTheDocument(); - expect(getByText(searchItems.indexPattern.title)).toBeInTheDocument(); + expect(getByText(searchItems.dataView.title)).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index 8d023e2ae430d..32bc4023f06f1 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -67,7 +67,7 @@ export interface StepDefineFormProps { export const StepDefineForm: FC = React.memo((props) => { const { searchItems } = props; - const { indexPattern } = searchItems; + const { dataView } = searchItems; const { ml: { DataGrid }, } = useAppDependencies(); @@ -88,7 +88,7 @@ export const StepDefineForm: FC = React.memo((props) => { const indexPreviewProps = { ...useIndexData( - indexPattern, + dataView, stepDefineForm.searchBar.state.pivotQuery, stepDefineForm.runtimeMappingsEditor.state.runtimeMappings ), @@ -101,7 +101,7 @@ export const StepDefineForm: FC = React.memo((props) => { : stepDefineForm.latestFunctionConfig; const previewRequest = getPreviewTransformRequestBody( - indexPattern.title, + dataView.title, pivotQuery, stepDefineForm.transformFunction === TRANSFORM_FUNCTION.PIVOT ? stepDefineForm.pivotConfig.state.requestPayload @@ -109,7 +109,7 @@ export const StepDefineForm: FC = React.memo((props) => { stepDefineForm.runtimeMappingsEditor.state.runtimeMappings ); - const copyToClipboardSource = getIndexDevConsoleStatement(pivotQuery, indexPattern.title); + const copyToClipboardSource = getIndexDevConsoleStatement(pivotQuery, dataView.title); const copyToClipboardSourceDescription = i18n.translate( 'xpack.transform.indexPreview.copyClipboardTooltip', { @@ -127,7 +127,7 @@ export const StepDefineForm: FC = React.memo((props) => { const pivotPreviewProps = { ...usePivotData( - indexPattern.title, + dataView.title, pivotQuery, validationStatus, requestPayload, @@ -211,7 +211,7 @@ export const StepDefineForm: FC = React.memo((props) => { defaultMessage: 'Data view', })} > - {indexPattern.title} + {dataView.title} )} @@ -233,10 +233,7 @@ export const StepDefineForm: FC = React.memo((props) => { {searchItems.savedSearch === undefined && ( <> {!isAdvancedSourceEditorEnabled && ( - + )} {isAdvancedSourceEditorEnabled && } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx index 1e3fa2026061b..1b2d5872e53b6 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx @@ -33,10 +33,10 @@ describe('Transform: ', () => { const mlSharedImports = await getMlSharedImports(); const searchItems = { - indexPattern: { - title: 'the-index-pattern-title', + dataView: { + title: 'the-data-view-title', fields: [] as any[], - } as SearchItems['indexPattern'], + } as SearchItems['dataView'], }; const groupBy: PivotGroupByConfig = { agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx index 2abb3f4c4cda8..2bae20da65067 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx @@ -56,14 +56,14 @@ export const StepDefineSummary: FC = ({ const pivotQuery = getPivotQuery(searchQuery); const previewRequest = getPreviewTransformRequestBody( - searchItems.indexPattern.title, + searchItems.dataView.title, pivotQuery, partialPreviewRequest, runtimeMappings ); const pivotPreviewProps = usePivotData( - searchItems.indexPattern.title, + searchItems.dataView.title, pivotQuery, validationStatus, partialPreviewRequest, @@ -92,7 +92,7 @@ export const StepDefineSummary: FC = ({ defaultMessage: 'Data view', })} > - {searchItems.indexPattern.title} + {searchItems.dataView.title} {typeof searchString === 'string' && ( ; } @@ -40,7 +40,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState { return { continuousModeDateField: '', continuousModeDelay: defaultContinuousModeDelay, - createIndexPattern: true, + createDataView: true, isContinuousModeEnabled: false, isRetentionPolicyEnabled: false, retentionPolicyDateField: '', @@ -53,7 +53,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState { destinationIngestPipeline: '', touched: false, valid: false, - indexPatternTimeField: undefined, + dataViewTimeField: undefined, }; } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index 75ed5c10f0483..aa08049ac9d64 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -49,7 +49,7 @@ import { getPreviewTransformRequestBody, isTransformIdValid, } from '../../../../common'; -import { EsIndexName, IndexPatternTitle } from './common'; +import { EsIndexName, DataViewTitle } from './common'; import { continuousModeDelayValidator, retentionPolicyMaxAgeValidator, @@ -99,14 +99,12 @@ export const StepDetailsForm: FC = React.memo( ); // Index pattern state - const [indexPatternTitles, setIndexPatternTitles] = useState([]); - const [createIndexPattern, setCreateIndexPattern] = useState( - canCreateDataView === false ? false : defaults.createIndexPattern + const [dataViewTitles, setDataViewTitles] = useState([]); + const [createDataView, setCreateDataView] = useState( + canCreateDataView === false ? false : defaults.createDataView ); - const [indexPatternAvailableTimeFields, setIndexPatternAvailableTimeFields] = useState< - string[] - >([]); - const [indexPatternTimeField, setIndexPatternTimeField] = useState(); + const [dataViewAvailableTimeFields, setDataViewAvailableTimeFields] = useState([]); + const [dataViewTimeField, setDataViewTimeField] = useState(); const onTimeFieldChanged = React.useCallback( (e: React.ChangeEvent) => { @@ -117,11 +115,11 @@ export const StepDetailsForm: FC = React.memo( } // Find the time field based on the selected value // this is to account for undefined when user chooses not to use a date field - const timeField = indexPatternAvailableTimeFields.find((col) => col === value); + const timeField = dataViewAvailableTimeFields.find((col) => col === value); - setIndexPatternTimeField(timeField); + setDataViewTimeField(timeField); }, - [setIndexPatternTimeField, indexPatternAvailableTimeFields] + [setDataViewTimeField, dataViewAvailableTimeFields] ); const { overlays, theme } = useAppDependencies(); @@ -134,7 +132,7 @@ export const StepDetailsForm: FC = React.memo( const { searchQuery, previewRequest: partialPreviewRequest } = stepDefineState; const pivotQuery = getPivotQuery(searchQuery); const previewRequest = getPreviewTransformRequestBody( - searchItems.indexPattern.title, + searchItems.dataView.title, pivotQuery, partialPreviewRequest, stepDefineState.runtimeMappings @@ -148,8 +146,8 @@ export const StepDetailsForm: FC = React.memo( (col) => properties[col].type === 'date' ); - setIndexPatternAvailableTimeFields(timeFields); - setIndexPatternTimeField(timeFields[0]); + setDataViewAvailableTimeFields(timeFields); + setDataViewTimeField(timeFields[0]); } else { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformPreview', { @@ -228,7 +226,7 @@ export const StepDetailsForm: FC = React.memo( } try { - setIndexPatternTitles(await deps.data.indexPatterns.getTitles()); + setDataViewTitles(await deps.data.dataViews.getTitles()); } catch (e) { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingDataViewTitles', { @@ -245,7 +243,7 @@ export const StepDetailsForm: FC = React.memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const dateFieldNames = searchItems.indexPattern.fields + const dateFieldNames = searchItems.dataView.fields .filter((f) => f.type === KBN_FIELD_TYPES.DATE) .map((f) => f.name) .sort(); @@ -291,7 +289,7 @@ export const StepDetailsForm: FC = React.memo( const indexNameExists = indexNames.some((name) => destinationIndex === name); const indexNameEmpty = destinationIndex === ''; const indexNameValid = isValidIndexName(destinationIndex); - const indexPatternTitleExists = indexPatternTitles.some((name) => destinationIndex === name); + const dataViewTitleExists = dataViewTitles.some((name) => destinationIndex === name); const [transformFrequency, setTransformFrequency] = useState(defaults.transformFrequency); const isTransformFrequencyValid = transformFrequencyValidator(transformFrequency); @@ -313,7 +311,7 @@ export const StepDetailsForm: FC = React.memo( isTransformSettingsMaxPageSearchSizeValid && !indexNameEmpty && indexNameValid && - (!indexPatternTitleExists || !createIndexPattern) && + (!dataViewTitleExists || !createDataView) && (!isContinuousModeAvailable || (isContinuousModeAvailable && isContinuousModeDelayValid)) && (!isRetentionPolicyAvailable || !isRetentionPolicyEnabled || @@ -327,7 +325,7 @@ export const StepDetailsForm: FC = React.memo( onChange({ continuousModeDateField, continuousModeDelay, - createIndexPattern, + createDataView, isContinuousModeEnabled, isRetentionPolicyEnabled, retentionPolicyDateField, @@ -341,7 +339,7 @@ export const StepDetailsForm: FC = React.memo( destinationIngestPipeline, touched: true, valid, - indexPatternTimeField, + dataViewTimeField, _meta: defaults._meta, }); // custom comparison @@ -349,7 +347,7 @@ export const StepDetailsForm: FC = React.memo( }, [ continuousModeDateField, continuousModeDelay, - createIndexPattern, + createDataView, isContinuousModeEnabled, isRetentionPolicyEnabled, retentionPolicyDateField, @@ -361,7 +359,7 @@ export const StepDetailsForm: FC = React.memo( destinationIndex, destinationIngestPipeline, valid, - indexPatternTimeField, + dataViewTimeField, /* eslint-enable react-hooks/exhaustive-deps */ ]); @@ -530,9 +528,7 @@ export const StepDetailsForm: FC = React.memo( ) : null} = React.memo( , ] : []), - ...(createIndexPattern && indexPatternTitleExists + ...(createDataView && dataViewTitleExists ? [ i18n.translate('xpack.transform.stepDetailsForm.dataViewTitleError', { defaultMessage: 'A data view with this title already exists.', @@ -553,25 +549,23 @@ export const StepDetailsForm: FC = React.memo( ]} > setCreateIndexPattern(!createIndexPattern)} - data-test-subj="transformCreateIndexPatternSwitch" + checked={createDataView === true} + onChange={() => setCreateDataView(!createDataView)} + data-test-subj="transformCreateDataViewSwitch" /> - {createIndexPattern && - !indexPatternTitleExists && - indexPatternAvailableTimeFields.length > 0 && ( - - )} + {createDataView && !dataViewTitleExists && dataViewAvailableTimeFields.length > 0 && ( + + )} {/* Continuous mode */} = React.memo((props) => { const { continuousModeDateField, - createIndexPattern, + createDataView, isContinuousModeEnabled, isRetentionPolicyEnabled, retentionPolicyDateField, @@ -28,14 +28,14 @@ export const StepDetailsSummary: FC = React.memo((props destinationIndex, destinationIngestPipeline, touched, - indexPatternTimeField, + dataViewTimeField, } = props; if (touched === false) { return null; } - const destinationIndexHelpText = createIndexPattern + const destinationIndexHelpText = createDataView ? i18n.translate('xpack.transform.stepDetailsSummary.createDataViewMessage', { defaultMessage: 'A Kibana data view will be created for this transform.', }) @@ -69,13 +69,13 @@ export const StepDetailsSummary: FC = React.memo((props > {destinationIndex} - {createIndexPattern && indexPatternTimeField !== undefined && indexPatternTimeField !== '' && ( + {createDataView && dataViewTimeField !== undefined && dataViewTimeField !== '' && ( - {indexPatternTimeField} + {dataViewTimeField} )} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx index 8d7f6b451f985..d750bf6c7e1fd 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx @@ -11,14 +11,14 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; interface Props { - indexPatternAvailableTimeFields: string[]; - indexPatternTimeField: string | undefined; + dataViewAvailableTimeFields: string[]; + dataViewTimeField: string | undefined; onTimeFieldChanged: (e: React.ChangeEvent) => void; } export const StepDetailsTimeField: FC = ({ - indexPatternAvailableTimeFields, - indexPatternTimeField, + dataViewAvailableTimeFields, + dataViewTimeField, onTimeFieldChanged, }) => { const noTimeFieldLabel = i18n.translate( @@ -56,13 +56,13 @@ export const StepDetailsTimeField: FC = ({ > ({ text })), + ...dataViewAvailableTimeFields.map((text) => ({ text })), disabledDividerOption, noTimeFieldOption, ]} - value={indexPatternTimeField} + value={dataViewTimeField} onChange={onTimeFieldChanged} - data-test-subj="transformIndexPatternTimeFieldSelect" + data-test-subj="transformDataViewTimeFieldSelect" /> ); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index 27c43ed01a934..c16756d0923e9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -31,7 +31,7 @@ import { StepDetailsSummary, } from '../step_details'; import { WizardNav } from '../wizard_nav'; -import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../src/plugins/data_views/public'; import type { RuntimeMappings } from '../step_define/common/types'; enum WIZARD_STEPS { @@ -86,26 +86,22 @@ interface WizardProps { } export const CreateTransformWizardContext = createContext<{ - indexPattern: IndexPattern | null; + dataView: DataView | null; runtimeMappings: RuntimeMappings | undefined; }>({ - indexPattern: null, + dataView: null, runtimeMappings: undefined, }); export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) => { - const { indexPattern } = searchItems; + const { dataView } = searchItems; // The current WIZARD_STEP const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.DEFINE); // The DEFINE state const [stepDefineState, setStepDefineState] = useState( - applyTransformConfigToDefineState( - getDefaultStepDefineState(searchItems), - cloneConfig, - indexPattern - ) + applyTransformConfigToDefineState(getDefaultStepDefineState(searchItems), cloneConfig, dataView) ); // The DETAILS state @@ -117,7 +113,7 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) const [stepCreateState, setStepCreateState] = useState(getDefaultStepCreateState); const transformConfig = getCreateTransformRequestBody( - indexPattern.title, + dataView.title, stepDefineState, stepDetailsState ); @@ -180,12 +176,12 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) {currentStep === WIZARD_STEPS.CREATE ? ( ) : ( @@ -200,19 +196,19 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) }, [ currentStep, setCurrentStep, - stepDetailsState.createIndexPattern, + stepDetailsState.createDataView, stepDetailsState.transformId, transformConfig, setStepCreateState, stepCreateState, - stepDetailsState.indexPatternTimeField, + stepDetailsState.dataViewTimeField, ]); const stepsConfig = [stepDefine, stepDetails, stepCreate]; return ( diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx index cf2ec765dc06b..f6c700aef67cc 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx @@ -22,23 +22,23 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => const history = useHistory(); const appDeps = useAppDependencies(); const savedObjectsClient = appDeps.savedObjects.client; - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const toastNotifications = useToastNotifications(); - const { getIndexPatternIdByTitle, loadIndexPatterns } = useSearchItems(undefined); + const { getDataViewIdByTitle, loadDataViews } = useSearchItems(undefined); const { canCreateTransform } = useContext(AuthorizationContext).capabilities; const clickHandler = useCallback( async (item: TransformListRow) => { try { - await loadIndexPatterns(savedObjectsClient, indexPatterns); + await loadDataViews(savedObjectsClient, dataViews); const dataViewTitle = Array.isArray(item.config.source.index) ? item.config.source.index.join(',') : item.config.source.index; - const indexPatternId = getIndexPatternIdByTitle(dataViewTitle); + const dataViewId = getDataViewIdByTitle(dataViewTitle); - if (indexPatternId === undefined) { + if (dataViewId === undefined) { toastNotifications.addDanger( i18n.translate('xpack.transform.clone.noDataViewErrorPromptText', { defaultMessage: @@ -47,9 +47,7 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => }) ); } else { - history.push( - `/${SECTION_SLUG.CLONE_TRANSFORM}/${item.id}?indexPatternId=${indexPatternId}` - ); + history.push(`/${SECTION_SLUG.CLONE_TRANSFORM}/${item.id}?dataViewId=${dataViewId}`); } } catch (e) { toastNotifications.addError(e, { @@ -62,10 +60,10 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => [ history, savedObjectsClient, - indexPatterns, + dataViews, toastNotifications, - loadIndexPatterns, - getIndexPatternIdByTitle, + loadDataViews, + getDataViewIdByTitle, ] ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx index d5436d51c218b..e369d9e992e30 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx @@ -23,12 +23,12 @@ export const DeleteActionModal: FC = ({ closeModal, deleteAndCloseModal, deleteDestIndex, - deleteIndexPattern, - indexPatternExists, + deleteDataView, + dataViewExists, items, shouldForceDelete, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, userCanDeleteIndex, userCanDeleteDataView, }) => { @@ -81,15 +81,15 @@ export const DeleteActionModal: FC = ({ { } @@ -130,11 +130,11 @@ export const DeleteActionModal: FC = ({ /> )} - {userCanDeleteIndex && indexPatternExists && ( + {userCanDeleteIndex && dataViewExists && ( = ({ values: { destinationIndex: items[0] && items[0].config.dest.index }, } )} - checked={deleteIndexPattern} - onChange={toggleDeleteIndexPattern} + checked={deleteDataView} + onChange={toggleDeleteDataView} disabled={userCanDeleteDataView === false} /> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx index b41dfe1c06a8a..357809b54746b 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx @@ -40,18 +40,18 @@ export const useDeleteAction = (forceDisable: boolean) => { userCanDeleteIndex, userCanDeleteDataView, deleteDestIndex, - indexPatternExists, - deleteIndexPattern, + dataViewExists, + deleteDataView, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, } = useDeleteIndexAndTargetIndex(items); const deleteAndCloseModal = () => { setModalVisible(false); const shouldDeleteDestIndex = userCanDeleteIndex && deleteDestIndex; - const shouldDeleteDestIndexPattern = - userCanDeleteIndex && userCanDeleteDataView && indexPatternExists && deleteIndexPattern; + const shouldDeleteDestDataView = + userCanDeleteIndex && userCanDeleteDataView && dataViewExists && deleteDataView; // if we are deleting multiple transforms, then force delete all if at least one item has failed // else, force delete only when the item user picks has failed const forceDelete = isBulkAction @@ -64,7 +64,7 @@ export const useDeleteAction = (forceDisable: boolean) => { state: i.stats.state, })), deleteDestIndex: shouldDeleteDestIndex, - deleteDestIndexPattern: shouldDeleteDestIndexPattern, + deleteDestDataView: shouldDeleteDestDataView, forceDelete, }); }; @@ -103,14 +103,14 @@ export const useDeleteAction = (forceDisable: boolean) => { closeModal, deleteAndCloseModal, deleteDestIndex, - deleteIndexPattern, - indexPatternExists, + deleteDataView, + dataViewExists, isModalVisible, items, openModal, shouldForceDelete, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, userCanDeleteIndex, userCanDeleteDataView, }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx index 9c8945264f000..0f73f6aac40d3 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx @@ -52,7 +52,7 @@ describe('Transform: Transform List Actions ', () => { // prepare render( - + ); @@ -72,7 +72,7 @@ describe('Transform: Transform List Actions ', () => { itemCopy.stats.checkpointing.last.checkpoint = 0; render( - + ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx index 0a5342b3b0c25..f7cc72c2236b0 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx @@ -23,7 +23,7 @@ export const discoverActionNameText = i18n.translate( export const isDiscoverActionDisabled = ( items: TransformListRow[], forceDisable: boolean, - indexPatternExists: boolean + dataViewExists: boolean ) => { if (items.length !== 1) { return true; @@ -38,14 +38,14 @@ export const isDiscoverActionDisabled = ( const transformNeverStarted = stoppedTransform === true && transformProgress === undefined && isBatchTransform === true; - return forceDisable === true || indexPatternExists === false || transformNeverStarted === true; + return forceDisable === true || dataViewExists === false || transformNeverStarted === true; }; export interface DiscoverActionNameProps { - indexPatternExists: boolean; + dataViewExists: boolean; items: TransformListRow[]; } -export const DiscoverActionName: FC = ({ indexPatternExists, items }) => { +export const DiscoverActionName: FC = ({ dataViewExists, items }) => { const isBulkAction = items.length > 1; const item = items[0]; @@ -65,7 +65,7 @@ export const DiscoverActionName: FC = ({ indexPatternEx defaultMessage: 'Links to Discover are not supported as a bulk action.', } ); - } else if (!indexPatternExists) { + } else if (!dataViewExists) { disabledTransformMessage = i18n.translate( 'xpack.transform.transformList.discoverTransformNoDataViewToolTip', { diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx index 9b1d7ed066404..71a45b572f833 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx @@ -20,7 +20,7 @@ import { DiscoverActionName, } from './discover_action_name'; -const getIndexPatternTitleFromTargetIndex = (item: TransformListRow) => +const getDataViewTitleFromTargetIndex = (item: TransformListRow) => Array.isArray(item.config.dest.index) ? item.config.dest.index.join(',') : item.config.dest.index; export type DiscoverAction = ReturnType; @@ -28,60 +28,59 @@ export const useDiscoverAction = (forceDisable: boolean) => { const appDeps = useAppDependencies(); const { share } = appDeps; const savedObjectsClient = appDeps.savedObjects.client; - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const isDiscoverAvailable = !!appDeps.application.capabilities.discover?.show; - const { getIndexPatternIdByTitle, loadIndexPatterns } = useSearchItems(undefined); + const { getDataViewIdByTitle, loadDataViews } = useSearchItems(undefined); - const [indexPatternsLoaded, setIndexPatternsLoaded] = useState(false); + const [dataViewsLoaded, setDataViewsLoaded] = useState(false); useEffect(() => { - async function checkIndexPatternAvailability() { - await loadIndexPatterns(savedObjectsClient, indexPatterns); - setIndexPatternsLoaded(true); + async function checkDataViewAvailability() { + await loadDataViews(savedObjectsClient, dataViews); + setDataViewsLoaded(true); } - checkIndexPatternAvailability(); - }, [indexPatterns, loadIndexPatterns, savedObjectsClient]); + checkDataViewAvailability(); + }, [dataViews, loadDataViews, savedObjectsClient]); const clickHandler = useCallback( (item: TransformListRow) => { const locator = share.url.locators.get(DISCOVER_APP_LOCATOR); if (!locator) return; - const indexPatternTitle = getIndexPatternTitleFromTargetIndex(item); - const indexPatternId = getIndexPatternIdByTitle(indexPatternTitle); + const dataViewTitle = getDataViewTitleFromTargetIndex(item); + const dataViewId = getDataViewIdByTitle(dataViewTitle); locator.navigateSync({ - indexPatternId, + indexPatternId: dataViewId, }); }, - [getIndexPatternIdByTitle, share] + [getDataViewIdByTitle, share] ); - const indexPatternExists = useCallback( + const dataViewExists = useCallback( (item: TransformListRow) => { - const indexPatternTitle = getIndexPatternTitleFromTargetIndex(item); - const indexPatternId = getIndexPatternIdByTitle(indexPatternTitle); - return indexPatternId !== undefined; + const dataViewTitle = getDataViewTitleFromTargetIndex(item); + const dataViewId = getDataViewIdByTitle(dataViewTitle); + return dataViewId !== undefined; }, - [getIndexPatternIdByTitle] + [getDataViewIdByTitle] ); const action: TransformListAction = useMemo( () => ({ name: (item: TransformListRow) => { - return ; + return ; }, available: () => isDiscoverAvailable, enabled: (item: TransformListRow) => - indexPatternsLoaded && - !isDiscoverActionDisabled([item], forceDisable, indexPatternExists(item)), + dataViewsLoaded && !isDiscoverActionDisabled([item], forceDisable, dataViewExists(item)), description: discoverActionNameText, icon: 'visTable', type: 'icon', onClick: clickHandler, 'data-test-subj': 'transformActionDiscover', }), - [forceDisable, indexPatternExists, indexPatternsLoaded, isDiscoverAvailable, clickHandler] + [forceDisable, dataViewExists, dataViewsLoaded, isDiscoverAvailable, clickHandler] ); return { action }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx index f789327a051e2..e4927fff97070 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx @@ -22,14 +22,14 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => const [config, setConfig] = useState(); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - const [indexPatternId, setIndexPatternId] = useState(); + const [dataViewId, setDataViewId] = useState(); const closeFlyout = () => setIsFlyoutVisible(false); - const { getIndexPatternIdByTitle } = useSearchItems(undefined); + const { getDataViewIdByTitle } = useSearchItems(undefined); const toastNotifications = useToastNotifications(); const appDeps = useAppDependencies(); - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const clickHandler = useCallback( async (item: TransformListRow) => { @@ -37,9 +37,9 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => const dataViewTitle = Array.isArray(item.config.source.index) ? item.config.source.index.join(',') : item.config.source.index; - const currentIndexPatternId = getIndexPatternIdByTitle(dataViewTitle); + const currentDataViewId = getDataViewIdByTitle(dataViewTitle); - if (currentIndexPatternId === undefined) { + if (currentDataViewId === undefined) { toastNotifications.addWarning( i18n.translate('xpack.transform.edit.noDataViewErrorPromptText', { defaultMessage: @@ -48,7 +48,7 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => }) ); } - setIndexPatternId(currentIndexPatternId); + setDataViewId(currentDataViewId); setConfig(item.config); setIsFlyoutVisible(true); } catch (e) { @@ -60,7 +60,7 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [indexPatterns, toastNotifications, getIndexPatternIdByTitle] + [dataViews, toastNotifications, getDataViewIdByTitle] ); const action: TransformListAction = useMemo( @@ -81,6 +81,6 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => config, closeFlyout, isFlyoutVisible, - indexPatternId, + dataViewId, }; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx index b988b61c5b0b7..e6648c5214dac 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx @@ -44,13 +44,13 @@ import { isManagedTransform } from '../../../../common/managed_transforms_utils' interface EditTransformFlyoutProps { closeFlyout: () => void; config: TransformConfigUnion; - indexPatternId?: string; + dataViewId?: string; } export const EditTransformFlyout: FC = ({ closeFlyout, config, - indexPatternId, + dataViewId, }) => { const api = useApi(); const toastNotifications = useToastNotifications(); @@ -110,10 +110,7 @@ export const EditTransformFlyout: FC = ({ /> ) : null} }> - + {errorMessage !== undefined && ( <> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx index 22f31fc6139e8..fd0ca655f3056 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx @@ -29,12 +29,12 @@ import { KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/com interface EditTransformFlyoutFormProps { editTransformFlyout: UseEditTransformFlyoutReturnType; - indexPatternId?: string; + dataViewId?: string; } export const EditTransformFlyoutForm: FC = ({ editTransformFlyout: [state, dispatch], - indexPatternId, + dataViewId, }) => { const { formFields, formSections } = state; const [dateFieldNames, setDateFieldNames] = useState([]); @@ -43,16 +43,16 @@ export const EditTransformFlyoutForm: FC = ({ const isRetentionPolicyAvailable = dateFieldNames.length > 0; const appDeps = useAppDependencies(); - const indexPatternsClient = appDeps.data.indexPatterns; + const dataViewsClient = appDeps.data.dataViews; const api = useApi(); useEffect( function getDateFields() { let unmounted = false; - if (indexPatternId !== undefined) { - indexPatternsClient.get(indexPatternId).then((indexPattern) => { - if (indexPattern) { - const dateTimeFields = indexPattern.fields + if (dataViewId !== undefined) { + dataViewsClient.get(dataViewId).then((dataView) => { + if (dataView) { + const dateTimeFields = dataView.fields .filter((f) => f.type === KBN_FIELD_TYPES.DATE) .map((f) => f.name) .sort(); @@ -66,7 +66,7 @@ export const EditTransformFlyoutForm: FC = ({ }; } }, - [indexPatternId, indexPatternsClient] + [dataViewId, dataViewsClient] ); useEffect(function fetchPipelinesOnMount() { @@ -153,7 +153,7 @@ export const EditTransformFlyoutForm: FC = ({ { // If data view or date fields info not available // gracefully defaults to text input - indexPatternId ? ( + dataViewId ? ( = ({ transf const pivotQuery = useMemo(() => getPivotQuery(searchQuery), [searchQuery]); - const indexPatternTitle = Array.isArray(transformConfig.source.index) + const dataViewTitle = Array.isArray(transformConfig.source.index) ? transformConfig.source.index.join(',') : transformConfig.source.index; const pivotPreviewProps = usePivotData( - indexPatternTitle, + dataViewTitle, pivotQuery, validationStatus, previewRequest, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx index 5d480003c7600..986adb89bd41e 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx @@ -52,7 +52,7 @@ export const useActions = ({ )} {deleteAction.isModalVisible && } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index 066a72c807956..a5c536990353a 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -192,7 +192,7 @@ export const TransformManagement: FC = () => { state: TRANSFORM_STATE.FAILED, })), deleteDestIndex: false, - deleteDestIndexPattern: false, + deleteDestDataView: false, forceDelete: true, } ); diff --git a/x-pack/plugins/transform/public/app/services/es_index_service.ts b/x-pack/plugins/transform/public/app/services/es_index_service.ts index c8d3f625a9281..88b54a7487f92 100644 --- a/x-pack/plugins/transform/public/app/services/es_index_service.ts +++ b/x-pack/plugins/transform/public/app/services/es_index_service.ts @@ -7,7 +7,7 @@ import { HttpSetup, SavedObjectsClientContract } from 'kibana/public'; import { API_BASE_PATH } from '../../../common/constants'; -import { IIndexPattern } from '../../../../../../src/plugins/data/common'; +import { DataView } from '../../../../../../src/plugins/data_views/public'; export class IndexService { async canDeleteIndex(http: HttpSetup) { @@ -18,8 +18,8 @@ export class IndexService { return privilege.hasAllPrivileges; } - async indexPatternExists(savedObjectsClient: SavedObjectsClientContract, indexName: string) { - const response = await savedObjectsClient.find({ + async dataViewExists(savedObjectsClient: SavedObjectsClientContract, indexName: string) { + const response = await savedObjectsClient.find({ type: 'index-pattern', perPage: 1, search: `"${indexName}"`, diff --git a/x-pack/plugins/transform/server/routes/api/field_histograms.ts b/x-pack/plugins/transform/server/routes/api/field_histograms.ts index bfe2f47078569..5f464949a4fc8 100644 --- a/x-pack/plugins/transform/server/routes/api/field_histograms.ts +++ b/x-pack/plugins/transform/server/routes/api/field_histograms.ts @@ -5,10 +5,7 @@ * 2.0. */ -import { - indexPatternTitleSchema, - IndexPatternTitleSchema, -} from '../../../common/api_schemas/common'; +import { dataViewTitleSchema, DataViewTitleSchema } from '../../../common/api_schemas/common'; import { fieldHistogramsRequestSchema, FieldHistogramsRequestSchema, @@ -21,23 +18,23 @@ import { addBasePath } from '../index'; import { wrapError, wrapEsError } from './error_utils'; export function registerFieldHistogramsRoutes({ router, license }: RouteDependencies) { - router.post( + router.post( { - path: addBasePath('field_histograms/{indexPatternTitle}'), + path: addBasePath('field_histograms/{dataViewTitle}'), validate: { - params: indexPatternTitleSchema, + params: dataViewTitleSchema, body: fieldHistogramsRequestSchema, }, }, - license.guardApiRoute( + license.guardApiRoute( async (ctx, req, res) => { - const { indexPatternTitle } = req.params; + const { dataViewTitle } = req.params; const { query, fields, runtimeMappings, samplerShardSize } = req.body; try { const resp = await getHistogramsForFields( ctx.core.elasticsearch.client, - indexPatternTitle, + dataViewTitle, query, fields, samplerShardSize, diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index 2f82b9a70389b..78b51fca58547 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -61,7 +61,7 @@ import { addBasePath } from '../index'; import { isRequestTimeout, fillResultsWithTimeouts, wrapError, wrapEsError } from './error_utils'; import { registerTransformsAuditMessagesRoutes } from './transforms_audit_messages'; import { registerTransformNodesRoutes } from './transforms_nodes'; -import { IIndexPattern } from '../../../../../../src/plugins/data/common'; +import { DataView } from '../../../../../../src/plugins/data_views/common'; import { isLatestTransform } from '../../../common/types/transform'; import { isKeywordDuplicate } from '../../../common/utils/field_utils'; import { transformHealthServiceProvider } from '../../lib/alerting/transform_health_rule_type/transform_health_service'; @@ -449,11 +449,8 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { registerTransformNodesRoutes(routeDependencies); } -async function getIndexPatternId( - indexName: string, - savedObjectsClient: SavedObjectsClientContract -) { - const response = await savedObjectsClient.find({ +async function getDataViewId(indexName: string, savedObjectsClient: SavedObjectsClientContract) { + const response = await savedObjectsClient.find({ type: 'index-pattern', perPage: 1, search: `"${indexName}"`, @@ -464,11 +461,11 @@ async function getIndexPatternId( return ip?.id; } -async function deleteDestIndexPatternById( - indexPatternId: string, +async function deleteDestDataViewById( + dataViewId: string, savedObjectsClient: SavedObjectsClientContract ) { - return await savedObjectsClient.delete('index-pattern', indexPatternId); + return await savedObjectsClient.delete('index-pattern', dataViewId); } async function deleteTransforms( @@ -480,7 +477,7 @@ async function deleteTransforms( // Cast possible undefineds as booleans const deleteDestIndex = !!reqBody.deleteDestIndex; - const deleteDestIndexPattern = !!reqBody.deleteDestIndexPattern; + const deleteDestDataView = !!reqBody.deleteDestDataView; const shouldForceDelete = !!reqBody.forceDelete; const results: DeleteTransformsResponseSchema = {}; @@ -490,7 +487,7 @@ async function deleteTransforms( const transformDeleted: ResponseStatus = { success: false }; const destIndexDeleted: ResponseStatus = { success: false }; - const destIndexPatternDeleted: ResponseStatus = { + const destDataViewDeleted: ResponseStatus = { success: false, }; const transformId = transformInfo.id; @@ -516,7 +513,7 @@ async function deleteTransforms( results[transformId] = { transformDeleted, destIndexDeleted, - destIndexPatternDeleted, + destDataViewDeleted, destinationIndex, }; // No need to perform further delete attempts @@ -538,18 +535,15 @@ async function deleteTransforms( } // Delete the data view if there's a data view that matches the name of dest index - if (destinationIndex && deleteDestIndexPattern) { + if (destinationIndex && deleteDestDataView) { try { - const indexPatternId = await getIndexPatternId( - destinationIndex, - ctx.core.savedObjects.client - ); - if (indexPatternId) { - await deleteDestIndexPatternById(indexPatternId, ctx.core.savedObjects.client); - destIndexPatternDeleted.success = true; + const dataViewId = await getDataViewId(destinationIndex, ctx.core.savedObjects.client); + if (dataViewId) { + await deleteDestDataViewById(dataViewId, ctx.core.savedObjects.client); + destDataViewDeleted.success = true; } - } catch (deleteDestIndexPatternError) { - destIndexPatternDeleted.error = deleteDestIndexPatternError.meta.body.error; + } catch (deleteDestDataViewError) { + destDataViewDeleted.error = deleteDestDataViewError.meta.body.error; } } @@ -569,7 +563,7 @@ async function deleteTransforms( results[transformId] = { transformDeleted, destIndexDeleted, - destIndexPatternDeleted, + destDataViewDeleted, destinationIndex, }; } catch (e) { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 8914efcf12ded..1595abb458a25 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -530,7 +530,6 @@ "xpack.lens.indexPattern.ranges.lessThanTooltip": "Inférieur à", "xpack.lens.indexPattern.records": "Enregistrements", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "Sous-fonction", - "xpack.lens.indexPattern.removeColumnAriaLabel": "Ajouter ou glisser-déposer un champ dans {groupLabel}", "xpack.lens.indexPattern.removeColumnLabel": "Retirer la configuration de \"{groupLabel}\"", "xpack.lens.indexPattern.removeFieldLabel": "Retirer le champ du modèle d'indexation", "xpack.lens.indexPattern.sortField.invalid": "Champ non valide. Vérifiez votre modèle d'indexation ou choisissez un autre champ.", @@ -5862,7 +5861,6 @@ "xpack.apm.alertTypes.transactionDuration.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Type : \\{\\{context.transactionType\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil de latence : \\{\\{context.threshold\\}\\} ms\n- Latence observée : \\{\\{context.triggerValue\\}\\} sur la dernière période de \\{\\{context.interval\\}\\}", "xpack.apm.alertTypes.transactionDuration.description": "Alerte lorsque la latence d'un type de transaction spécifique dans un service dépasse le seuil défini.", "xpack.apm.alertTypes.transactionDurationAnomaly.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Type : \\{\\{context.transactionType\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil de sévérité : \\{\\{context.threshold\\}\\}\n- Valeur de sévérité : \\{\\{context.triggerValue\\}\\}\n", - "xpack.apm.alertTypes.transactionDurationAnomaly.description": "Alerte lorsque la latence d'un service est anormale.", "xpack.apm.alertTypes.transactionErrorRate.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Type : \\{\\{context.transactionType\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil : \\{\\{context.threshold\\}\\} %\n- Valeur de déclenchement : \\{\\{context.triggerValue\\}\\} % des erreurs sur la dernière période de \\{\\{context.interval\\}\\}", "xpack.apm.alertTypes.transactionErrorRate.description": "Alerte lorsque le taux d'erreurs de transaction d'un service dépasse un seuil défini.", "xpack.apm.analyzeDataButton.label": "Analyser les données", @@ -6406,7 +6404,6 @@ "xpack.apm.transactionDurationAlert.name": "Seuil de latence", "xpack.apm.transactionDurationAlertTrigger.ms": "ms", "xpack.apm.transactionDurationAlertTrigger.when": "Quand", - "xpack.apm.transactionDurationAnomalyAlert.name": "Anomalie de latence", "xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity": "Comporte une anomalie avec sévérité", "xpack.apm.transactionDurationLabel": "Durée", "xpack.apm.transactionErrorRateAlert.name": "Seuil du taux de transactions ayant échoué", @@ -8729,7 +8726,6 @@ "xpack.enterpriseSearch.appSearch.documents.search.sortBy.ariaLabel": "Trier les résultats par", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.ascendingDropDownOptionLabel": "{fieldName} (croiss.)", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.descendingDropDownOptionLabel": "{fieldName} (décroiss.)", - "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded": "Récemment chargé", "xpack.enterpriseSearch.appSearch.documents.title": "Documents", "xpack.enterpriseSearch.appSearch.editorRoleTypeDescription": "Les éditeurs peuvent gérer les paramètres de recherche.", "xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta": "Créer un moteur", @@ -11927,7 +11923,6 @@ "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.phaseDefinitionTitle": "Définition de la phase", "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.showPhaseDefinitionButton": "Afficher la définition", "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.showPhaseDefinitionDescriptionTitle": "Définition de la phase", - "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.stackTraceButton": "Trace de la pile", "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.summaryErrorMessage": "Erreur de cycle de vie des index", "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.summaryTitle": "Gestion du cycle de vie des index", "xpack.indexLifecycleMgmt.indexManagementTable.addLifecyclePolicyConfirmModal.addPolicyButtonText": "Ajouter une stratégie", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 48f0d74d73765..e469741130081 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -611,7 +611,6 @@ "xpack.lens.indexPattern.rareTermsOf": "{name}の希少な値", "xpack.lens.indexPattern.records": "記録", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "サブ関数", - "xpack.lens.indexPattern.removeColumnAriaLabel": "フィールドを追加するか、{groupLabel}までドラッグアンドドロップします", "xpack.lens.indexPattern.removeColumnLabel": "「{groupLabel}」から構成を削除", "xpack.lens.indexPattern.removeFieldLabel": "データビューフィールドを削除", "xpack.lens.indexPattern.sortField.invalid": "無効なフィールドです。データビューを確認するか、別のフィールドを選択してください。", @@ -1222,13 +1221,9 @@ "controls.controlGroup.management.flyoutTitle": "コントロールを構成", "controls.controlGroup.management.layout.auto": "自動", "controls.controlGroup.management.layout.controlWidthLegend": "コントロールサイズを変更", - "controls.controlGroup.management.layout.designSwitchLegend": "コントロール設計を切り替え", "controls.controlGroup.management.layout.large": "大", "controls.controlGroup.management.layout.medium": "中", - "controls.controlGroup.management.layout.singleLine": "1行", "controls.controlGroup.management.layout.small": "小", - "controls.controlGroup.management.layout.twoLine": "2行", - "controls.controlGroup.management.layoutTitle": "レイアウト", "controls.controlGroup.management.setAllWidths": "すべてのサイズをデフォルトに設定", "controls.controlGroup.title": "コントロールグループ", "controls.optionsList.editor.allowMultiselectTitle": "ドロップダウンでの複数選択を許可", @@ -6828,8 +6823,6 @@ "xpack.apm.alertTypes.transactionDuration.defaultActionMessage": "次の条件のため、\\{\\{alertName\\}\\}アラートが実行されています。\n\n- サービス名:\\{\\{context.serviceName\\}\\}\n- タイプ:\\{\\{context.transactionType\\}\\}\n- 環境:\\{\\{context.environment\\}\\}\n- レイテンシしきい値:\\{\\{context.threshold\\}\\}ミリ秒\n- 観察されたレイテンシ:直前の\\{\\{context.interval\\}\\}に\\{\\{context.triggerValue\\}\\}", "xpack.apm.alertTypes.transactionDuration.description": "サービスの特定のトランザクションタイプのレイテンシが定義されたしきい値を超えたときにアラートを発行します。", "xpack.apm.alertTypes.transactionDurationAnomaly.defaultActionMessage": "次の条件のため、\\{\\{alertName\\}\\}アラートが実行されています。\n\n- サービス名:\\{\\{context.serviceName\\}\\}\n- タイプ:\\{\\{context.transactionType\\}\\}\n- 環境:\\{\\{context.environment\\}\\}\n- 重要度しきい値:\\{\\{context.threshold\\}\\}%\n- 重要度値:\\{\\{context.triggerValue\\}\\}\n", - "xpack.apm.alertTypes.transactionDurationAnomaly.description": "サービスのレイテンシが異常であるときにアラートを表示します。", - "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "過去{interval}における{serviceName}のスコア{measured}の{severityLevel}異常が検知されました。", "xpack.apm.alertTypes.transactionErrorRate.defaultActionMessage": "次の条件のため、\\{\\{alertName\\}\\}アラートが実行されています。\n\n- サービス名:\\{\\{context.serviceName\\}\\}\n- タイプ:\\{\\{context.transactionType\\}\\}\n- 環境:\\{\\{context.environment\\}\\}\n- しきい値:\\{\\{context.threshold\\}\\}%\n- トリガーされた値:過去\\{\\{context.interval\\}\\}にエラーの\\{\\{context.triggerValue\\}\\}%", "xpack.apm.alertTypes.transactionErrorRate.description": "サービスのトランザクションエラー率が定義されたしきい値を超過したときにアラートを発行します。", "xpack.apm.analyzeDataButton.label": "データの探索", @@ -7643,7 +7636,6 @@ "xpack.apm.transactionDurationAlert.name": "レイテンシしきい値", "xpack.apm.transactionDurationAlertTrigger.ms": "ms", "xpack.apm.transactionDurationAlertTrigger.when": "タイミング", - "xpack.apm.transactionDurationAnomalyAlert.name": "レイテンシ異常値", "xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity": "異常と重要度があります", "xpack.apm.transactionDurationLabel": "期間", "xpack.apm.transactionErrorRateAlert.name": "失敗したトランザクション率しきい値", @@ -10279,7 +10271,6 @@ "xpack.enterpriseSearch.appSearch.documents.search.sortBy.ariaLabel": "結果の並べ替え条件", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.ascendingDropDownOptionLabel": "{fieldName}(昇順)", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.descendingDropDownOptionLabel": "{fieldName}(降順)", - "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded": "最近アップロードされたドキュメント", "xpack.enterpriseSearch.appSearch.documents.title": "ドキュメント", "xpack.enterpriseSearch.appSearch.editorRoleTypeDescription": "エディターは検索設定を管理できます。", "xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta": "エンジンを作成", @@ -13977,7 +13968,6 @@ "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.phaseDefinitionTitle": "フェーズ検知", "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.showPhaseDefinitionButton": "フェーズ検知を表示", "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.showPhaseDefinitionDescriptionTitle": "フェーズ検知", - "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.stackTraceButton": "スタックトレース", "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.summaryErrorMessage": "インデックスライフサイクルエラー", "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.summaryTitle": "インデックスライフサイクル管理", "xpack.indexLifecycleMgmt.indexManagementTable.addLifecyclePolicyConfirmModal.addPolicyButtonText": "ポリシーを追加", @@ -25212,8 +25202,6 @@ "xpack.securitySolution.pages.common.solutionName": "セキュリティ", "xpack.securitySolution.pages.common.updateAlertStatusFailed": "{ conflicts } {conflicts, plural, other {アラート}}を更新できませんでした。", "xpack.securitySolution.pages.common.updateAlertStatusFailedDetailed": "{ updated } {updated, plural, other {アラート}}が正常に更新されましたが、{ conflicts }は更新できませんでした。\n { conflicts, plural, other {}}すでに修正されています。", - "xpack.securitySolution.pages.emptyPage.beatsCard.description": "Elasticエージェントを使用して、セキュリティイベントを収集し、エンドポイントを脅威から保護してください。", - "xpack.securitySolution.pages.emptyPage.beatsCard.title": "セキュリティ統合を追加", "xpack.securitySolution.pages.fourohfour.pageNotFoundDescription": "ページが見つかりません", "xpack.securitySolution.paginatedTable.rowsButtonLabel": "ページごとの行数", "xpack.securitySolution.paginatedTable.showingSubtitle": "表示中", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index bbc00d8d205f7..165d3814809b7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -617,7 +617,6 @@ "xpack.lens.indexPattern.rareTermsOf": "{name} 的稀有值", "xpack.lens.indexPattern.records": "记录", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "子函数", - "xpack.lens.indexPattern.removeColumnAriaLabel": "将字段添加或拖放到 {groupLabel}", "xpack.lens.indexPattern.removeColumnLabel": "从“{groupLabel}”中删除配置", "xpack.lens.indexPattern.removeFieldLabel": "移除数据视图字段", "xpack.lens.indexPattern.sortField.invalid": "字段无效。检查数据视图或选取其他字段。", @@ -1228,13 +1227,9 @@ "controls.controlGroup.management.flyoutTitle": "配置控件", "controls.controlGroup.management.layout.auto": "自动", "controls.controlGroup.management.layout.controlWidthLegend": "更改控件大小", - "controls.controlGroup.management.layout.designSwitchLegend": "切换控件设计", "controls.controlGroup.management.layout.large": "大", "controls.controlGroup.management.layout.medium": "中", - "controls.controlGroup.management.layout.singleLine": "单行", "controls.controlGroup.management.layout.small": "小", - "controls.controlGroup.management.layout.twoLine": "双行", - "controls.controlGroup.management.layoutTitle": "布局", "controls.controlGroup.management.setAllWidths": "将所有大小设为默认值", "controls.controlGroup.title": "控件组", "controls.optionsList.editor.allowMultiselectTitle": "下拉列表中允许多选", @@ -6842,8 +6837,6 @@ "xpack.apm.alertTypes.transactionDuration.description": "当服务中特定事务类型的延迟超过定义的阈值时告警。", "xpack.apm.alertTypes.transactionDuration.reason": "对于 {serviceName},过去 {interval}的 {aggregationType} 延迟为 {measured}。超出 {threshold} 时告警。", "xpack.apm.alertTypes.transactionDurationAnomaly.defaultActionMessage": "由于以下条件 \\{\\{alertName\\}\\} 告警触发:\n\n- 服务名称:\\{\\{context.serviceName\\}\\}\n- 类型:\\{\\{context.transactionType\\}\\}\n- 环境:\\{\\{context.environment\\}\\}\n- 严重性阈值:\\{\\{context.threshold\\}\\}\n- 严重性值:\\{\\{context.triggerValue\\}\\}\n", - "xpack.apm.alertTypes.transactionDurationAnomaly.description": "服务的延迟异常时告警。", - "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "对于 {serviceName},过去 {interval}检测到分数为 {measured} 的 {severityLevel} 异常。", "xpack.apm.alertTypes.transactionErrorRate.defaultActionMessage": "由于以下条件 \\{\\{alertName\\}\\} 告警触发:\n\n- 服务名称:\\{\\{context.serviceName\\}\\}\n- 类型:\\{\\{context.transactionType\\}\\}\n- 环境:\\{\\{context.environment\\}\\}\n- 阈值:\\{\\{context.threshold\\}\\}%\n- 已触发的值:在过去 \\{\\{context.interval\\}\\}有 \\{\\{context.triggerValue\\}\\}% 的错误", "xpack.apm.alertTypes.transactionErrorRate.description": "当服务中的事务错误率超过定义的阈值时告警。", "xpack.apm.alertTypes.transactionErrorRate.reason": "对于 {serviceName},过去 {interval}的失败事务数为 {measured}。超出 {threshold} 时告警。", @@ -7661,7 +7654,6 @@ "xpack.apm.transactionDurationAlert.name": "延迟阈值", "xpack.apm.transactionDurationAlertTrigger.ms": "ms", "xpack.apm.transactionDurationAlertTrigger.when": "当", - "xpack.apm.transactionDurationAnomalyAlert.name": "延迟异常", "xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity": "有异常,严重性为", "xpack.apm.transactionDurationLabel": "持续时间", "xpack.apm.transactionErrorRateAlert.name": "失败事务率阈值", @@ -10300,7 +10292,6 @@ "xpack.enterpriseSearch.appSearch.documents.search.sortBy.ariaLabel": "结果排序方式", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.ascendingDropDownOptionLabel": "{fieldName}(升序)", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.descendingDropDownOptionLabel": "{fieldName}(降序)", - "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded": "最近上传", "xpack.enterpriseSearch.appSearch.documents.title": "文档", "xpack.enterpriseSearch.appSearch.editorRoleTypeDescription": "编辑人员可以管理搜索设置。", "xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta": "创建引擎", @@ -14000,7 +13991,6 @@ "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.phaseDefinitionTitle": "阶段定义", "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.showPhaseDefinitionButton": "显示阶段定义", "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.showPhaseDefinitionDescriptionTitle": "阶段定义", - "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.stackTraceButton": "堆栈跟踪", "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.summaryErrorMessage": "索引生命周期错误", "xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.summaryTitle": "索引生命周期管理", "xpack.indexLifecycleMgmt.indexManagementTable.addLifecyclePolicyConfirmModal.addPolicyButtonText": "添加策略", @@ -25242,8 +25232,6 @@ "xpack.securitySolution.pages.common.solutionName": "安全", "xpack.securitySolution.pages.common.updateAlertStatusFailed": "无法更新{ conflicts } 个{conflicts, plural, other {告警}}。", "xpack.securitySolution.pages.common.updateAlertStatusFailedDetailed": "{ updated } 个{updated, plural, other {告警}}已成功更新,但是 { conflicts } 个无法更新,\n 因为{ conflicts, plural, other {其}}已被修改。", - "xpack.securitySolution.pages.emptyPage.beatsCard.description": "使用 Elastic 代理来收集安全事件并防止您的终端受到威胁。", - "xpack.securitySolution.pages.emptyPage.beatsCard.title": "添加安全集成", "xpack.securitySolution.pages.fourohfour.pageNotFoundDescription": "未找到页面", "xpack.securitySolution.paginatedTable.rowsButtonLabel": "每页行数", "xpack.securitySolution.paginatedTable.showingSubtitle": "正在显示", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx index e6b5fdbdb1883..dddfc357f2eaa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx @@ -31,6 +31,11 @@ import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/config_api', () => ({ + triggersActionsUiConfig: jest + .fn() + .mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }), +})); jest.mock('react-router-dom', () => ({ useHistory: () => ({ push: jest.fn(), @@ -142,6 +147,24 @@ describe('rule_details', () => { ).toBeTruthy(); }); + it('displays a toast message when interval is less than configured minimum', async () => { + const rule = mockRule({ + schedule: { + interval: '1s', + }, + }); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(useKibanaMock().services.notifications.toasts.addInfo).toHaveBeenCalled(); + }); + describe('actions', () => { it('renders an rule action', () => { const rule = mockRule({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx index de948c2fd21de..736178cc5ab3e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx @@ -27,11 +27,18 @@ import { EuiPageTemplate, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { AlertExecutionStatusErrorReasons } from '../../../../../../alerting/common'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { AlertExecutionStatusErrorReasons, parseDuration } from '../../../../../../alerting/common'; import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; import { getAlertingSectionBreadcrumb, getRuleDetailsBreadcrumb } from '../../../lib/breadcrumb'; import { getCurrentDocTitle } from '../../../lib/doc_title'; -import { Rule, RuleType, ActionType, ActionConnector } from '../../../../types'; +import { + Rule, + RuleType, + ActionType, + ActionConnector, + TriggersActionsUiConfig, +} from '../../../../types'; import { ComponentOpts as BulkOperationsComponentOpts, withBulkRuleOperations, @@ -47,6 +54,7 @@ import { import { useKibana } from '../../../../common/lib/kibana'; import { ruleReducer } from '../../rule_form/rule_reducer'; import { loadAllActions as loadConnectors } from '../../../lib/action_connector_api'; +import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; export type RuleDetailsProps = { rule: Rule; @@ -75,6 +83,7 @@ export const RuleDetails: React.FunctionComponent = ({ setBreadcrumbs, chrome, http, + notifications: { toasts }, } = useKibana().services; const [{}, dispatch] = useReducer(ruleReducer, { rule }); const setInitialRule = (value: Rule) => { @@ -84,6 +93,14 @@ export const RuleDetails: React.FunctionComponent = ({ const [hasActionsWithBrokenConnector, setHasActionsWithBrokenConnector] = useState(false); + const [config, setConfig] = useState({}); + + useEffect(() => { + (async () => { + setConfig(await triggersActionsUiConfig({ http })); + })(); + }, [http]); + // Set breadcrumb and page title useEffect(() => { setBreadcrumbs([ @@ -141,6 +158,53 @@ export const RuleDetails: React.FunctionComponent = ({ const [dismissRuleErrors, setDismissRuleErrors] = useState(false); const [dismissRuleWarning, setDismissRuleWarning] = useState(false); + // Check whether interval is below configured minium + useEffect(() => { + if (rule.schedule.interval && config.minimumScheduleInterval) { + if ( + parseDuration(rule.schedule.interval) < parseDuration(config.minimumScheduleInterval.value) + ) { + const configurationToast = toasts.addInfo({ + 'data-test-subj': 'intervalConfigToast', + title: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.scheduleIntervalToastTitle', + { + defaultMessage: 'Configuration settings', + } + ), + text: toMountPoint( + <> +

+ +

+ {hasEditButton && ( + + + { + toasts.remove(configurationToast); + setEditFlyoutVisibility(true); + }} + > + + + + + )} + + ), + }); + } + } + }, [rule.schedule.interval, config.minimumScheduleInterval, toasts, hasEditButton]); + const setRule = async () => { history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx index 1289b81eb8169..032d69fa7ccc4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx @@ -19,6 +19,11 @@ import { spacesPluginMock } from '../../../../../../spaces/public/mocks'; import { useKibana } from '../../../../common/lib/kibana'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/config_api', () => ({ + triggersActionsUiConfig: jest + .fn() + .mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }), +})); describe('rule_details_route', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 36c102c6f54bb..021ea3c2d0055 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -169,7 +169,7 @@ describe('rules_list component with items', () => { tags: ['tag1'], enabled: true, ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, + schedule: { interval: '1s' }, actions: [], params: { name: 'test rule type name' }, scheduledTaskId: null, @@ -476,6 +476,19 @@ describe('rules_list component with items', () => { wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-interval"]').length ).toEqual(mockedRulesData.length); + // Schedule interval tooltip + wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOver'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + wrapper.update(); + expect(wrapper.find('.euiToolTipPopover').text()).toBe( + 'Below configured minimum intervalRule interval of 1 second is below the minimum configured interval of 1 minute. This may impact alerting performance.' + ); + + wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOut'); + // Duration column expect( wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-duration"]').length diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index c55f1303120f0..3cb1ac7b93dca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -48,6 +48,7 @@ import { RuleTypeIndex, Pagination, Percentiles, + TriggersActionsUiConfig, } from '../../../../types'; import { RuleAdd, RuleEdit } from '../../rule_form'; import { BulkOperationPopover } from '../../common/components/bulk_operation_popover'; @@ -75,6 +76,7 @@ import { ALERTS_FEATURE_ID, AlertExecutionStatusErrorReasons, formatDuration, + parseDuration, MONITORING_HISTORY_LIMIT, } from '../../../../../../alerting/common'; import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; @@ -89,6 +91,7 @@ import { PercentileSelectablePopover } from './percentile_selectable_popover'; import { RuleDurationFormat } from './rule_duration_format'; import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; import { getFormattedSuccessRatio } from '../../../lib/monitoring_utils'; +import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; const ENTER_KEY = 13; @@ -135,6 +138,7 @@ export const RulesList: React.FunctionComponent = () => { const [initialLoad, setInitialLoad] = useState(true); const [noData, setNoData] = useState(true); + const [config, setConfig] = useState({}); const [actionTypes, setActionTypes] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [isPerformingAction, setIsPerformingAction] = useState(false); @@ -150,6 +154,12 @@ export const RulesList: React.FunctionComponent = () => { const [currentRuleToEdit, setCurrentRuleToEdit] = useState(null); const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); + useEffect(() => { + (async () => { + setConfig(await triggersActionsUiConfig({ http })); + })(); + }, [http]); + const [percentileOptions, setPercentileOptions] = useState(initialPercentileOptions); @@ -609,7 +619,59 @@ export const RulesList: React.FunctionComponent = () => { sortable: false, truncateText: false, 'data-test-subj': 'rulesTableCell-interval', - render: (interval: string) => formatDuration(interval), + render: (interval: string, item: RuleTableItem) => { + const durationString = formatDuration(interval); + return ( + <> + + {durationString} + + {item.showIntervalWarning && ( + + { + if (item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId)) { + onRuleEdit(item); + } + }} + iconType="flag" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.intervalIconAriaLabel', + { defaultMessage: 'Below configured minimum interval' } + )} + /> + + )} + + + + ); + }, }, { field: 'executionStatus.lastDuration', @@ -850,11 +912,12 @@ export const RulesList: React.FunctionComponent = () => { setIsPerformingAction(true)} onActionPerformed={() => { loadRulesData(); @@ -1037,7 +1100,12 @@ export const RulesList: React.FunctionComponent = () => { items={ ruleTypesState.isInitialized === false ? [] - : convertRulesToTableItems(rulesState.data, ruleTypesState.data, canExecuteActions) + : convertRulesToTableItems({ + rules: rulesState.data, + ruleTypeIndex: ruleTypesState.data, + canExecuteActions, + config, + }) } itemId="id" columns={getRulesTableColumns()} @@ -1202,19 +1270,29 @@ function filterRulesById(rules: Rule[], ids: string[]): Rule[] { return rules.filter((rule) => ids.includes(rule.id)); } -function convertRulesToTableItems( - rules: Rule[], - ruleTypeIndex: RuleTypeIndex, - canExecuteActions: boolean -) { - return rules.map((rule, index: number) => ({ - ...rule, - index, - actionsCount: rule.actions.length, - ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, - isEditable: - hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && - (canExecuteActions || (!canExecuteActions && !rule.actions.length)), - enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, - })); +interface ConvertRulesToTableItemsOpts { + rules: Rule[]; + ruleTypeIndex: RuleTypeIndex; + canExecuteActions: boolean; + config: TriggersActionsUiConfig; +} + +function convertRulesToTableItems(opts: ConvertRulesToTableItemsOpts): RuleTableItem[] { + const { rules, ruleTypeIndex, canExecuteActions, config } = opts; + const minimumDuration = config.minimumScheduleInterval + ? parseDuration(config.minimumScheduleInterval.value) + : 0; + return rules.map((rule, index: number) => { + return { + ...rule, + index, + actionsCount: rule.actions.length, + ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, + isEditable: + hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && + (canExecuteActions || (!canExecuteActions && !rule.actions.length)), + enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, + showIntervalWarning: parseDuration(rule.schedule.interval) < minimumDuration, + }; + }); } diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts index aa0321ef8346b..fe9f921fc7f88 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts @@ -7,7 +7,12 @@ import { HttpSetup } from 'kibana/public'; import { BASE_TRIGGERS_ACTIONS_UI_API_PATH } from '../../../common'; +import { TriggersActionsUiConfig } from '../../types'; -export async function triggersActionsUiConfig({ http }: { http: HttpSetup }): Promise { +export async function triggersActionsUiConfig({ + http, +}: { + http: HttpSetup; +}): Promise { return await http.get(`${BASE_TRIGGERS_ACTIONS_UI_API_PATH}/_config`); } diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index eb346e43cfbc9..b1ef489bfef70 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -55,6 +55,7 @@ export { deleteRules } from './application/lib/rule_api/delete'; export { enableRule } from './application/lib/rule_api/enable'; export { disableRule } from './application/lib/rule_api/disable'; export { muteRule } from './application/lib/rule_api/mute'; +export { unmuteRule } from './application/lib/rule_api/unmute'; export { loadRuleAggregations } from './application/lib/rule_api/aggregate'; export { useLoadRuleTypes } from './application/hooks/use_load_rule_types'; diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 0835ef2b7453e..7a1efaed33abf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -251,6 +251,7 @@ export interface RuleTableItem extends Rule { actionsCount: number; isEditable: boolean; enabledInLicense: boolean; + showIntervalWarning?: boolean; } export interface RuleTypeParamsExpressionProps< diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx new file mode 100644 index 0000000000000..3ae0c013d694f --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; + +import { AppDependencies } from '../../../public/types'; +import { setupEnvironment, kibanaVersion, getAppContextMock } from '../helpers'; +import { AppTestBed, setupAppPage } from './app.helpers'; + +describe('Privileges', () => { + let testBed: AppTestBed; + let httpSetup: ReturnType['httpSetup']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpSetup = mockEnvironment.httpSetup; + }); + + describe('when user is not a Kibana global admin', () => { + beforeEach(async () => { + const appContextMock = getAppContextMock(kibanaVersion) as unknown as AppDependencies; + const servicesMock = { + ...appContextMock.services, + core: { + ...appContextMock.services.core, + application: { + capabilities: { + spaces: { + manage: false, + }, + }, + }, + }, + }; + + await act(async () => { + testBed = await setupAppPage(httpSetup, { services: servicesMock }); + }); + + testBed.component.update(); + }); + + test('renders not authorized message', () => { + const { exists } = testBed; + expect(exists('overview')).toBe(false); + expect(exists('missingKibanaPrivilegesMessage')).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts index 3ddfeb3b057ea..3ceadecb208df 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts @@ -88,7 +88,14 @@ export const getAppContextMock = (kibanaVersion: SemVer) => ({ notifications: notificationServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), history: scopedHistoryMock.create(), - application: applicationServiceMock.createStartContract(), + application: { + ...applicationServiceMock.createStartContract(), + capabilities: { + spaces: { + manage: true, + }, + }, + }, }, }, plugins: { diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts index f70bfd00e9c07..4ae44f0027069 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts @@ -7,3 +7,4 @@ export { setupEnvironment, WithAppDependencies, kibanaVersion } from './setup_environment'; export { advanceTime } from './time_manipulation'; +export { getAppContextMock } from './app_context.mock'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/app.tsx b/x-pack/plugins/upgrade_assistant/public/application/app.tsx index 4b2b85638e8be..00c910fd648f7 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/app.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/app.tsx @@ -19,6 +19,7 @@ import { AuthorizationProvider, RedirectAppLinks, KibanaThemeProvider, + NotAuthorizedSection, } from '../shared_imports'; import { AppDependencies } from '../types'; import { AppContextProvider, useAppContext } from './app_context'; @@ -35,18 +36,46 @@ const { GlobalFlyoutProvider } = GlobalFlyout; const AppHandlingClusterUpgradeState: React.FunctionComponent = () => { const { isReadOnlyMode, - services: { api }, + services: { api, core }, } = useAppContext(); - const [clusterUpgradeState, setClusterUpradeState] = + const missingManageSpacesPrivilege = core.application.capabilities.spaces.manage !== true; + + const [clusterUpgradeState, setClusterUpgradeState] = useState('isPreparingForUpgrade'); useEffect(() => { api.onClusterUpgradeStateChange((newClusterUpgradeState: ClusterUpgradeState) => { - setClusterUpradeState(newClusterUpgradeState); + setClusterUpgradeState(newClusterUpgradeState); }); }, [api]); + if (missingManageSpacesPrivilege) { + return ( + + + } + message={ + + } + /> + + ); + } + // Read-only mode will be enabled up until the last minor before the next major release if (isReadOnlyMode) { return ; diff --git a/x-pack/plugins/upgrade_assistant/public/shared_imports.ts b/x-pack/plugins/upgrade_assistant/public/shared_imports.ts index 988bb1363398b..7d23f88a95c44 100644 --- a/x-pack/plugins/upgrade_assistant/public/shared_imports.ts +++ b/x-pack/plugins/upgrade_assistant/public/shared_imports.ts @@ -22,6 +22,7 @@ export { WithPrivileges, AuthorizationProvider, AuthorizationContext, + NotAuthorizedSection, } from '../../../../src/plugins/es_ui_shared/public/'; export { Storage } from '../../../../src/plugins/kibana_utils/public'; diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts b/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts index d43fd5ad001f2..74a3bba6ae027 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts @@ -6,7 +6,7 @@ */ import * as t from 'io-ts'; -import { PingType } from '../ping/ping'; +import { PingErrorType, PingType } from '../ping/ping'; export const StateType = t.intersection([ t.type({ @@ -27,6 +27,7 @@ export const StateType = t.intersection([ monitor: t.intersection([ t.partial({ name: t.string, + checkGroup: t.string, duration: t.type({ us: t.number }), }), t.type({ @@ -47,6 +48,7 @@ export const StateType = t.intersection([ service: t.partial({ name: t.string, }), + error: PingErrorType, }), ]); diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts b/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts index c63f5eb838d60..e0205b9362e23 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts @@ -222,7 +222,9 @@ export const SyntheticsMonitorWithIdCodec = t.intersection([ export type SyntheticsMonitorWithId = t.TypeOf; export const MonitorManagementListResultCodec = t.type({ - monitors: t.array(t.interface({ id: t.string, attributes: SyntheticsMonitorCodec })), + monitors: t.array( + t.interface({ id: t.string, attributes: SyntheticsMonitorCodec, updated_at: t.string }) + ), page: t.number, perPage: t.number, total: t.union([t.number, t.null]), diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts index e78f026277d3a..6208e42868d9e 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts @@ -180,8 +180,14 @@ export const PingType = t.intersection([ }), }), observer: t.partial({ + hostname: t.string, + ip: t.array(t.string), + mac: t.array(t.string), geo: t.partial({ name: t.string, + continent_name: t.string, + city_name: t.string, + country_iso_code: t.string, location: t.union([ t.string, t.partial({ lat: t.number, lon: t.number }), @@ -221,6 +227,11 @@ export const PingType = t.intersection([ name: t.string, }), config_id: t.string, + data_stream: t.interface({ + namespace: t.string, + type: t.string, + dataset: t.string, + }), }), ]); diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts index a143063d221b9..c95f9c281dc92 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts @@ -79,6 +79,9 @@ export const JourneyStepType = t.intersection([ }), }), synthetics: SyntheticsDataType, + error: t.type({ + message: t.string, + }), }), t.type({ _id: t.string, diff --git a/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts b/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts index 1d6270c00df65..309cc5eb0ec6d 100644 --- a/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts +++ b/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts @@ -202,7 +202,7 @@ journey('Monitor Management breadcrumbs', async ({ page, params }: { page: Page; step('edit http monitor and check breadcrumb', async () => { await uptime.editMonitor(); // breadcrumb is available before edit page is loaded, make sure its edit view - await page.waitForSelector(byTestId('monitorManagementMonitorName')); + await page.waitForSelector(byTestId('monitorManagementMonitorName'), { timeout: 60 * 1000 }); const breadcrumbs = await page.$$('[data-test-subj=breadcrumb]'); expect(await breadcrumbs[1].textContent()).toEqual('Monitor management'); const lastBreadcrumb = await (await uptime.findByTestSubj('"breadcrumb last"')).textContent(); diff --git a/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx b/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx index a19f14fa1a6d1..b56cd8a361684 100644 --- a/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx +++ b/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx @@ -23,7 +23,7 @@ export function monitorManagementPageProvider({ const remotePassword = process.env.SYNTHETICS_REMOTE_KIBANA_PASSWORD; const isRemote = Boolean(process.env.SYNTHETICS_REMOTE_ENABLED); const basePath = isRemote ? remoteKibanaUrl : kibanaUrl; - const monitorManagement = `${basePath}/app/uptime/manage-monitors`; + const monitorManagement = `${basePath}/app/uptime/manage-monitors/all`; const addMonitor = `${basePath}/app/uptime/add-monitor`; const overview = `${basePath}/app/uptime`; return { diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 28a49067b6698..0ae53fe56b1a4 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -7,6 +7,7 @@ "alerting", "cases", "embeddable", + "discover", "encryptedSavedObjects", "features", "inspector", diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index a5e2a85953d65..278ce45cdf593 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -16,6 +16,7 @@ import { from } from 'rxjs'; import { map } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { SharePluginSetup, SharePluginStart } from '../../../../../src/plugins/share/public'; +import { DiscoverStart } from '../../../../../src/plugins/discover/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../../src/core/public'; import { @@ -61,6 +62,7 @@ export interface ClientPluginsSetup { export interface ClientPluginsStart { fleet?: FleetStart; data: DataPublicPluginStart; + discover: DiscoverStart; inspector: InspectorPluginStart; embeddable: EmbeddableStart; observability: ObservabilityPublicStart; @@ -213,7 +215,14 @@ export class UptimePlugin const [coreStart, corePlugins] = await core.getStartServices(); const { renderApp } = await import('./render_app'); - return renderApp(coreStart, plugins, corePlugins, params, config); + return renderApp( + coreStart, + plugins, + corePlugins, + params, + config, + this.initContext.env.mode.dev + ); }, }); } diff --git a/x-pack/plugins/uptime/public/apps/render_app.tsx b/x-pack/plugins/uptime/public/apps/render_app.tsx index 23f8fc9a8e58c..44e9651c25dd1 100644 --- a/x-pack/plugins/uptime/public/apps/render_app.tsx +++ b/x-pack/plugins/uptime/public/apps/render_app.tsx @@ -25,7 +25,8 @@ export function renderApp( plugins: ClientPluginsSetup, startPlugins: ClientPluginsStart, appMountParameters: AppMountParameters, - config: UptimeUiConfig + config: UptimeUiConfig, + isDev: boolean ) { const { application: { capabilities }, @@ -45,6 +46,7 @@ export function renderApp( plugins.share.url.locators.create(uptimeOverviewNavigatorParams); const props: UptimeAppProps = { + isDev, plugins, canSave, core, diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 5df0d1a00f905..12519143d347a 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -65,6 +65,7 @@ export interface UptimeAppProps { setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; appMountParameters: AppMountParameters; config: UptimeUiConfig; + isDev: boolean; } const Application = (props: UptimeAppProps) => { diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx index 985b1ae9146f2..0c059580b5461 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx @@ -85,7 +85,7 @@ export function ActionMenuContent({ config }: { config: UptimeConfig }): React.R color="text" data-test-subj="management-page-link" href={history.createHref({ - pathname: MONITOR_MANAGEMENT_ROUTE, + pathname: MONITOR_MANAGEMENT_ROUTE + '/all', })} > (); + const { isDev } = useUptimeSettingsContext(); + const { inspectorAdapters } = useInspectorContext(); const isInspectorEnabled = uiSettings?.get(enableInspectEsQueries); @@ -25,7 +28,7 @@ export function InspectorHeaderLink() { inspector.open(inspectorAdapters); }; - if (!isInspectorEnabled) { + if (!isInspectorEnabled && !isDev) { return null; } 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 8a2e669129114..3b30458974ed7 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 @@ -145,7 +145,7 @@ export const ActionBar = ({ }, [data, status, isSaving, isValid, monitorId, hasErrors, locations, loading]); return isSuccessful ? ( - + ) : ( diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.test.tsx new file mode 100644 index 0000000000000..369aa1461c425 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { MockRedux } from '../../../lib/helper/rtl_helpers'; +import { useInlineErrors } from './use_inline_errors'; +import * as obsvPlugin from '../../../../../observability/public/hooks/use_es_search'; + +function mockNow(date: string | number | Date) { + const fakeNow = new Date(date).getTime(); + return jest.spyOn(Date, 'now').mockReturnValue(fakeNow); +} + +describe('useInlineErrors', function () { + it('it returns result as expected', async function () { + mockNow('2022-01-02T00:00:00.000Z'); + const spy = jest.spyOn(obsvPlugin, 'useEsSearch'); + + const { result } = renderHook(() => useInlineErrors({ onlyInvalidMonitors: true }), { + wrapper: MockRedux, + }); + + expect(result.current).toEqual({ + loading: true, + }); + + expect(spy).toHaveBeenNthCalledWith( + 3, + { + body: { + collapse: { field: 'config_id' }, + query: { + bool: { + filter: [ + { exists: { field: 'summary' } }, + { exists: { field: 'error' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match_phrase: { 'error.message': 'journey did not finish executing' } }, + { match_phrase: { 'error.message': 'ReferenceError:' } }, + ], + }, + }, + { + range: { + 'monitor.timespan': { + gte: '2022-01-01T23:55:00.000Z', + lte: '2022-01-02T00:00:00.000Z', + }, + }, + }, + { bool: { must_not: { exists: { field: 'run_once' } } } }, + ], + }, + }, + size: 1000, + sort: [{ '@timestamp': 'desc' }], + }, + index: 'heartbeat-8*,heartbeat-7*,synthetics-*', + }, + [ + 'heartbeat-8*,heartbeat-7*,synthetics-*', + { + error: { monitorList: null, serviceLocations: null }, + list: { monitors: [], page: 1, perPage: 10, total: null }, + loading: { monitorList: false, serviceLocations: false }, + locations: [], + }, + 1641081600000, + true, + '@timestamp', + 'desc', + ], + { name: 'getInvalidMonitors' } + ); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.ts b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.ts new file mode 100644 index 0000000000000..3753d95b8e858 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.ts @@ -0,0 +1,110 @@ +/* + * 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 { useSelector } from 'react-redux'; +import moment from 'moment'; +import { useMemo } from 'react'; +import { monitorManagementListSelector, selectDynamicSettings } from '../../../state/selectors'; +import { useEsSearch } from '../../../../../observability/public'; +import { Ping } from '../../../../common/runtime_types'; +import { EXCLUDE_RUN_ONCE_FILTER } from '../../../../common/constants/client_defaults'; +import { useUptimeRefreshContext } from '../../../contexts/uptime_refresh_context'; +import { useInlineErrorsCount } from './use_inline_errors_count'; + +const sortFieldMap: Record = { + name: 'monitor.name', + urls: 'url.full', + '@timestamp': '@timestamp', +}; + +export const getInlineErrorFilters = () => [ + { + exists: { + field: 'summary', + }, + }, + { + exists: { + field: 'error', + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'error.message': 'journey did not finish executing', + }, + }, + { + match_phrase: { + 'error.message': 'ReferenceError:', + }, + }, + ], + }, + }, + { + range: { + 'monitor.timespan': { + lte: moment().toISOString(), + gte: moment().subtract(5, 'minutes').toISOString(), + }, + }, + }, + EXCLUDE_RUN_ONCE_FILTER, +]; + +export function useInlineErrors({ + onlyInvalidMonitors, + sortField = '@timestamp', + sortOrder = 'desc', +}: { + onlyInvalidMonitors?: boolean; + sortField?: string; + sortOrder?: 'asc' | 'desc'; +}) { + const monitorList = useSelector(monitorManagementListSelector); + + const { settings } = useSelector(selectDynamicSettings); + + const { lastRefresh } = useUptimeRefreshContext(); + + const configIds = monitorList.list.monitors.map((monitor) => monitor.id); + + const doFetch = configIds.length > 0 || onlyInvalidMonitors; + + const { data, loading } = useEsSearch( + { + index: doFetch ? settings?.heartbeatIndices : '', + body: { + size: 1000, + query: { + bool: { + filter: getInlineErrorFilters(), + }, + }, + collapse: { field: 'config_id' }, + sort: [{ [sortFieldMap[sortField]]: sortOrder }], + }, + }, + [settings?.heartbeatIndices, monitorList, lastRefresh, doFetch, sortField, sortOrder], + { name: 'getInvalidMonitors' } + ); + + const { count, loading: countLoading } = useInlineErrorsCount(); + + return useMemo(() => { + const errorSummaries = data?.hits.hits.map(({ _source: source }) => ({ + ...(source as Ping), + timestamp: (source as any)['@timestamp'], + })); + + return { loading: loading || countLoading, errorSummaries, count }; + }, [count, countLoading, data, loading]); +} diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.test.tsx new file mode 100644 index 0000000000000..c4c864e7720cd --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.test.tsx @@ -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 { renderHook } from '@testing-library/react-hooks'; +import { MockRedux } from '../../../lib/helper/rtl_helpers'; +import { useInlineErrorsCount } from './use_inline_errors_count'; +import * as obsvPlugin from '../../../../../observability/public/hooks/use_es_search'; + +function mockNow(date: string | number | Date) { + const fakeNow = new Date(date).getTime(); + return jest.spyOn(Date, 'now').mockReturnValue(fakeNow); +} + +describe('useInlineErrorsCount', function () { + it('it returns result as expected', async function () { + mockNow('2022-01-02T00:00:00.000Z'); + const spy = jest.spyOn(obsvPlugin, 'useEsSearch'); + + const { result } = renderHook(() => useInlineErrorsCount(), { + wrapper: MockRedux, + }); + + expect(result.current).toEqual({ + loading: true, + }); + + expect(spy).toHaveBeenNthCalledWith( + 2, + { + body: { + aggs: { total: { cardinality: { field: 'config_id' } } }, + query: { + bool: { + filter: [ + { exists: { field: 'summary' } }, + { exists: { field: 'error' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match_phrase: { 'error.message': 'journey did not finish executing' } }, + { match_phrase: { 'error.message': 'ReferenceError:' } }, + ], + }, + }, + { + range: { + 'monitor.timespan': { + gte: '2022-01-01T23:55:00.000Z', + lte: '2022-01-02T00:00:00.000Z', + }, + }, + }, + { bool: { must_not: { exists: { field: 'run_once' } } } }, + ], + }, + }, + size: 0, + }, + index: 'heartbeat-8*,heartbeat-7*,synthetics-*', + }, + [ + 'heartbeat-8*,heartbeat-7*,synthetics-*', + { + error: { monitorList: null, serviceLocations: null }, + list: { monitors: [], page: 1, perPage: 10, total: null }, + loading: { monitorList: false, serviceLocations: false }, + locations: [], + }, + 1641081600000, + ], + { name: 'getInvalidMonitorsCount' } + ); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.ts b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.ts new file mode 100644 index 0000000000000..adda7c433b29c --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.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 { useSelector } from 'react-redux'; +import { useMemo } from 'react'; +import { monitorManagementListSelector, selectDynamicSettings } from '../../../state/selectors'; +import { useEsSearch } from '../../../../../observability/public'; +import { useUptimeRefreshContext } from '../../../contexts/uptime_refresh_context'; +import { getInlineErrorFilters } from './use_inline_errors'; + +export function useInlineErrorsCount() { + const monitorList = useSelector(monitorManagementListSelector); + + const { settings } = useSelector(selectDynamicSettings); + + const { lastRefresh } = useUptimeRefreshContext(); + + const { data, loading } = useEsSearch( + { + index: settings?.heartbeatIndices, + body: { + size: 0, + query: { + bool: { + filter: getInlineErrorFilters(), + }, + }, + aggs: { + total: { + cardinality: { field: 'config_id' }, + }, + }, + }, + }, + [settings?.heartbeatIndices, monitorList, lastRefresh], + { name: 'getInvalidMonitorsCount' } + ); + + return useMemo(() => { + const errorSummariesCount = data?.aggregations?.total.value; + + return { loading, count: errorSummariesCount }; + }, [data, loading]); +} diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_invalid_monitors.tsx b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_invalid_monitors.tsx new file mode 100644 index 0000000000000..98e882e543a87 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_invalid_monitors.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { useFetcher } from '../../../../../observability/public'; +import { Ping, SyntheticsMonitor } from '../../../../common/runtime_types'; +import { syntheticsMonitorType } from '../../../../common/types/saved_objects'; + +export const useInvalidMonitors = (errorSummaries?: Ping[]) => { + const { savedObjects } = useKibana().services; + + const ids = (errorSummaries ?? []).map((summary) => ({ + id: summary.config_id!, + type: syntheticsMonitorType, + })); + + return useFetcher(async () => { + if (ids.length > 0) { + const response = await savedObjects?.client.bulkResolve(ids); + if (response) { + const { resolved_objects: resolvedObjects } = response; + return resolvedObjects + .filter((sv) => { + if (sv.saved_object.updatedAt) { + const errorSummary = errorSummaries?.find( + (summary) => summary.config_id === sv.saved_object.id + ); + if (errorSummary) { + return moment(sv.saved_object.updatedAt).isBefore(moment(errorSummary.timestamp)); + } + } + + return !Boolean(sv.saved_object.error); + }) + .map(({ saved_object: savedObject }) => ({ + ...savedObject, + updated_at: savedObject.updatedAt!, + })); + } + } + }, [errorSummaries]); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.test.tsx index ec58ac7ee5010..f60d54e9cb4f6 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.test.tsx @@ -15,7 +15,7 @@ describe('', () => { const onUpdate = jest.fn(); it('navigates to edit monitor flow on edit pencil', () => { - render(); + render(); expect(screen.getByLabelText('Edit monitor')).toHaveAttribute( 'href', diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx index 9d84263f3701e..47a0b8547ea8c 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx @@ -8,19 +8,37 @@ import React, { useContext } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import moment from 'moment'; import { UptimeSettingsContext } from '../../../contexts'; import { DeleteMonitor } from './delete_monitor'; +import { InlineError } from './inline_error'; +import { MonitorManagementListResult, Ping } from '../../../../common/runtime_types'; interface Props { id: string; name: string; isDisabled?: boolean; onUpdate: () => void; + errorSummaries?: Ping[]; + monitors: MonitorManagementListResult['monitors']; } -export const Actions = ({ id, name, onUpdate, isDisabled }: Props) => { +export const Actions = ({ id, name, onUpdate, isDisabled, errorSummaries, monitors }: Props) => { const { basePath } = useContext(UptimeSettingsContext); + let errorSummary = errorSummaries?.find((summary) => summary.config_id === id); + + const monitor = monitors.find((monitorT) => monitorT.id === id); + + if (errorSummary && monitor) { + const summaryIsBeforeUpdate = moment(monitor.updated_at).isBefore( + moment(errorSummary.timestamp) + ); + if (!summaryIsBeforeUpdate) { + errorSummary = undefined; + } + } + return ( @@ -35,6 +53,11 @@ export const Actions = ({ id, name, onUpdate, isDisabled }: Props) => { + {errorSummary && ( + + + + )} ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/all_monitors.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/all_monitors.tsx new file mode 100644 index 0000000000000..550d3b487a4ae --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/all_monitors.tsx @@ -0,0 +1,34 @@ +/* + * 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 { useSelector } from 'react-redux'; +import { MonitorManagementList, MonitorManagementListPageState } from './monitor_list'; +import { monitorManagementListSelector } from '../../../state/selectors'; +import { MonitorManagementList as MonitorManagementListState } from '../../../state/reducers/monitor_management'; +import { Ping } from '../../../../common/runtime_types'; + +interface Props { + pageState: MonitorManagementListPageState; + monitorList: MonitorManagementListState; + onPageStateChange: (state: MonitorManagementListPageState) => void; + onUpdate: () => void; + errorSummaries: Ping[]; +} +export const AllMonitors = ({ pageState, onPageStateChange, onUpdate, errorSummaries }: Props) => { + const monitorList = useSelector(monitorManagementListSelector); + + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/delete_monitor.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/delete_monitor.test.tsx index 2e69196c86cff..f8a81a6efce0b 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/delete_monitor.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/delete_monitor.test.tsx @@ -40,7 +40,7 @@ describe('', () => { it('calls set refresh when deletion is successful', () => { const id = 'test-id'; const name = 'sample monitor'; - render(); + render(); userEvent.click(screen.getByLabelText('Delete monitor')); @@ -54,7 +54,7 @@ describe('', () => { status: FETCH_STATUS.LOADING, refetch: () => {}, }); - render(); + render(); expect(screen.getByLabelText('Deleting monitor...')).toBeInTheDocument(); }); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.test.tsx new file mode 100644 index 0000000000000..1cf05d7697e60 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.test.tsx @@ -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 React from 'react'; +import { screen } from '@testing-library/react'; +import { render } from '../../../lib/helper/rtl_helpers'; +import { InlineError } from './inline_error'; + +describe('', () => { + it('calls delete monitor on monitor deletion', () => { + render(); + + expect( + screen.getByLabelText( + 'journey did not finish executing, 0 steps ran. Click for more details.' + ) + ).toBeInTheDocument(); + }); +}); + +const errorSummary = { + docId: 'testDoc', + summary: { up: 0, down: 1 }, + agent: { + name: 'cron-686f0ce427dfd7e3-27465864-sqs5l', + id: '778fe9c6-bbd1-47d4-a0be-73f8ba9cec61', + type: 'heartbeat', + ephemeral_id: 'bc1a961f-1fbc-4253-aee0-633a8f6fc56e', + version: '8.1.0', + }, + synthetics: { type: 'heartbeat/summary' }, + monitor: { + name: 'Browser monitor', + check_group: 'f5480358-a9da-11ec-bced-6274e5883bd7', + id: '3a5553a0-a949-11ec-b7ca-c3b39fffa2af', + timespan: { lt: '2022-03-22T12:27:02.563Z', gte: '2022-03-22T12:24:02.563Z' }, + type: 'browser', + status: 'down', + }, + error: { type: 'io', message: 'journey did not finish executing, 0 steps ran' }, + url: {}, + observer: { + geo: { + continent_name: 'North America', + city_name: 'Iowa', + country_iso_code: 'US', + name: 'North America - US Central', + location: '41.8780, 93.0977', + }, + hostname: 'cron-686f0ce427dfd7e3-27465864-sqs5l', + ip: ['10.1.9.110'], + mac: ['62:74:e5:88:3b:d7'], + }, + ecs: { version: '8.0.0' }, + config_id: '3a5553a0-a949-11ec-b7ca-c3b39fffa2af', + data_stream: { namespace: 'default', type: 'synthetics', dataset: 'browser' }, + timestamp: '2022-03-22T12:24:02.563Z', +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.tsx new file mode 100644 index 0000000000000..187c81ff8c6e9 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.tsx @@ -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 React, { useState } from 'react'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Ping } from '../../../../common/runtime_types'; +import { StdErrorPopover } from './stderr_logs_popover'; + +export const InlineError = ({ errorSummary }: { errorSummary: Ping }) => { + const [isOpen, setIsOpen] = useState(false); + + const errorMessage = + errorSummary.monitor.type === 'browser' + ? getInlineErrorLabel(errorSummary.error?.message) + : errorSummary.error?.message; + + return ( + + setIsOpen(true)} + color="danger" + /> + + } + /> + ); +}; + +export const getInlineErrorLabel = (message?: string) => { + return i18n.translate('xpack.uptime.monitorList.statusColumn.error.message', { + defaultMessage: '{message}. Click for more details.', + values: { message }, + }); +}; + +export const ERROR_LOGS_LABEL = i18n.translate('xpack.uptime.monitorList.statusColumn.error.logs', { + defaultMessage: 'Error logs', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/invalid_monitors.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/invalid_monitors.tsx new file mode 100644 index 0000000000000..4b524a2b52312 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/invalid_monitors.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { MonitorManagementList, MonitorManagementListPageState } from './monitor_list'; +import { MonitorManagementListResult, Ping } from '../../../../common/runtime_types'; + +interface Props { + loading: boolean; + pageState: MonitorManagementListPageState; + monitorSavedObjects?: MonitorManagementListResult['monitors']; + onPageStateChange: (state: MonitorManagementListPageState) => void; + onUpdate: () => void; + errorSummaries: Ping[]; + invalidTotal: number; +} +export const InvalidMonitors = ({ + loading: summariesLoading, + pageState, + onPageStateChange, + onUpdate, + errorSummaries, + invalidTotal, + monitorSavedObjects, +}: Props) => { + const { pageSize, pageIndex } = pageState; + + const startIndex = (pageIndex - 1) * pageSize; + + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.test.tsx new file mode 100644 index 0000000000000..bfac60de96bc7 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.test.tsx @@ -0,0 +1,35 @@ +/* + * 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 { screen } from '@testing-library/react'; +import { render } from '../../../lib/helper/rtl_helpers'; +import { MonitorListTabs } from './list_tabs'; + +describe('', () => { + it('calls delete monitor on monitor deletion', () => { + const onPageStateChange = jest.fn(); + render( + + ); + + expect(screen.getByText('All monitors')).toBeInTheDocument(); + expect(screen.getByText('Invalid monitors')).toBeInTheDocument(); + + expect(onPageStateChange).toHaveBeenCalledTimes(1); + expect(onPageStateChange).toHaveBeenCalledWith({ + pageIndex: 1, + pageSize: 10, + sortField: 'name', + sortOrder: 'asc', + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.tsx new file mode 100644 index 0000000000000..1aad6d4d888e5 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.tsx @@ -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 { + EuiTabs, + EuiTab, + EuiNotificationBadge, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState, Fragment, useEffect } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import { useUptimeRefreshContext } from '../../../contexts/uptime_refresh_context'; +import { MonitorManagementListPageState } from './monitor_list'; +import { ConfigKey } from '../../../../common/runtime_types'; + +export const MonitorListTabs = ({ + invalidTotal, + onUpdate, + onPageStateChange, +}: { + invalidTotal: number; + onUpdate: () => void; + onPageStateChange: (state: MonitorManagementListPageState) => void; +}) => { + const [selectedTabId, setSelectedTabId] = useState('all'); + + const { refreshApp } = useUptimeRefreshContext(); + + const history = useHistory(); + + const { type: viewType } = useParams<{ type: 'all' | 'invalid' }>(); + + useEffect(() => { + setSelectedTabId(viewType); + onPageStateChange({ pageIndex: 1, pageSize: 10, sortOrder: 'asc', sortField: ConfigKey.NAME }); + }, [viewType, onPageStateChange]); + + const tabs = [ + { + id: 'all', + name: ALL_MONITORS_LABEL, + content: , + href: history.createHref({ pathname: '/manage-monitors/all' }), + disabled: false, + }, + { + id: 'invalid', + name: INVALID_MONITORS_LABEL, + append: ( + + {invalidTotal} + + ), + href: history.createHref({ pathname: '/manage-monitors/invalid' }), + content: , + disabled: invalidTotal === 0, + }, + ]; + + const onSelectedTabChanged = (id: string) => { + setSelectedTabId(id); + }; + + const renderTabs = () => { + return tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + append={tab.append} + > + {tab.name} + + )); + }; + + return ( + + + {renderTabs()} + + + { + onUpdate(); + refreshApp(); + }} + > + {REFRESH_LABEL} + + + + ); +}; + +export const REFRESH_LABEL = i18n.translate('xpack.uptime.monitorList.refresh', { + defaultMessage: 'Refresh', +}); + +export const INVALID_MONITORS_LABEL = i18n.translate('xpack.uptime.monitorList.invalidMonitors', { + defaultMessage: 'Invalid monitors', +}); + +export const ALL_MONITORS_LABEL = i18n.translate('xpack.uptime.monitorList.allMonitors', { + defaultMessage: 'All monitors', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.test.tsx index d9fb207f4fa20..ff5d9ebf13ccf 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.test.tsx @@ -20,6 +20,7 @@ describe('', () => { for (let i = 0; i < 12; i++) { monitors.push({ id: `test-monitor-id-${i}`, + updated_at: '123', attributes: { name: `test-monitor-${i}`, enabled: true, diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx index 5d18fdcaca6fe..8bae4160f6b0c 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx @@ -21,6 +21,7 @@ import { FetchMonitorManagementListQueryArgs, ICMPSimpleFields, MonitorFields, + Ping, ServiceLocations, SyntheticsMonitorWithId, TCPSimpleFields, @@ -47,6 +48,7 @@ interface Props { monitorList: MonitorManagementListState; onPageStateChange: (state: MonitorManagementListPageState) => void; onUpdate: () => void; + errorSummaries?: Ping[]; } export const MonitorManagementList = ({ @@ -58,13 +60,18 @@ export const MonitorManagementList = ({ }, onPageStateChange, onUpdate, + errorSummaries, }: Props) => { const { basePath } = useContext(UptimeSettingsContext); const isXl = useBreakpoints().up('xl'); const { total } = list as MonitorManagementListState['list']; const monitors: SyntheticsMonitorWithId[] = useMemo( - () => list.monitors.map((monitor) => ({ ...monitor.attributes, id: monitor.id })), + () => + list.monitors.map((monitor) => ({ + ...monitor.attributes, + id: monitor.id, + })), [list.monitors] ); @@ -90,7 +97,7 @@ export const MonitorManagementList = ({ pageIndex: pageIndex - 1, // page index for EuiBasicTable is base 0 pageSize, totalItemCount: total || 0, - pageSizeOptions: [10, 25, 50, 100], + pageSizeOptions: [5, 10, 25, 50, 100], }; const sorting: EuiTableSortingType = { @@ -188,6 +195,8 @@ export const MonitorManagementList = ({ name={fields[ConfigKey.NAME]} isDisabled={!canEdit} onUpdate={onUpdate} + errorSummaries={errorSummaries} + monitors={list.monitors} /> ), }, diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/stderr_logs_popover.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/stderr_logs_popover.tsx new file mode 100644 index 0000000000000..c50cd33b13b1f --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/stderr_logs_popover.tsx @@ -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 React from 'react'; +import { EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { StdErrorLogs } from '../../synthetics/check_steps/stderr_logs'; + +export const StdErrorPopover = ({ + checkGroup, + button, + isOpen, + setIsOpen, + summaryMessage, +}: { + isOpen: boolean; + setIsOpen: (val: boolean) => void; + checkGroup: string; + summaryMessage?: string; + button: JSX.Element; +}) => { + return ( + setIsOpen(false)} button={button}> + + + + + ); +}; + +const Container = styled.div` + width: 650px; + height: 400px; + overflow: scroll; +`; + +export const getInlineErrorLabel = (message?: string) => { + return i18n.translate('xpack.uptime.monitorList.statusColumn.error.messageLabel', { + defaultMessage: '{message}. Click for more details.', + values: { message }, + }); +}; + +export const ERROR_LOGS_LABEL = i18n.translate('xpack.uptime.monitorList.statusColumn.error.logs', { + defaultMessage: 'Error logs', +}); 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 c6074626bad1e..3e798dd3fbe62 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 @@ -75,7 +75,9 @@ export const BrowserTestRunResult = ({ monitorId, isMonitorSaved, onDone }: Prop initialIsOpen={true} > {isStepsLoading && {LOADING_STEPS}} - {isStepsLoadingFailed && {FAILED_TO_RUN}} + {isStepsLoadingFailed && ( + {summaryDoc?.error?.message ?? FAILED_TO_RUN} + )} {stepEnds.length > 0 && stepListData?.steps && ( 0) { summaryDocs.forEach((sDoc) => { - duration += sDoc.monitor.duration!.us; + duration += sDoc.monitor.duration?.us ?? 0; }); } + const summaryDoc = summaryDocs?.[0] as Ping; + return ( @@ -48,7 +50,9 @@ export function TestResultHeader({ doc, title, summaryDocs, journeyStarted, isCo {isCompleted ? ( - {COMPLETED_LABEL} + 0 ? 'danger' : 'success'}> + {summaryDoc?.summary?.down! > 0 ? FAILED_LABEL : COMPLETED_LABEL} + @@ -98,6 +102,10 @@ const COMPLETED_LABEL = i18n.translate('xpack.uptime.monitorManagement.completed defaultMessage: 'COMPLETED', }); +const FAILED_LABEL = i18n.translate('xpack.uptime.monitorManagement.failed', { + defaultMessage: 'FAILED', +}); + export const IN_PROGRESS_LABEL = i18n.translate('xpack.uptime.monitorManagement.inProgress', { defaultMessage: 'IN PROGRESS', }); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx index 60baedaa7830c..896ab6bc662bb 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useContext } from 'react'; +import React, { useCallback } from 'react'; import moment, { Moment } from 'moment'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; @@ -14,14 +14,13 @@ import { EuiFlexItem, EuiText, EuiToolTip, - EuiBadge, EuiSpacer, EuiHighlight, EuiHorizontalRule, } from '@elastic/eui'; import { useDispatch, useSelector } from 'react-redux'; import { parseTimestamp } from '../parse_timestamp'; -import { DataStream, Ping } from '../../../../../common/runtime_types'; +import { DataStream, Ping, PingError } from '../../../../../common/runtime_types'; import { STATUS, SHORT_TIMESPAN_LOCALE, @@ -29,22 +28,24 @@ import { SHORT_TS_LOCALE, } from '../../../../../common/constants'; -import { UptimeThemeContext } from '../../../../contexts'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../../../common/translations'; import { MonitorProgress } from './progress/monitor_progress'; import { refreshedMonitorSelector } from '../../../../state/reducers/monitor_list'; import { testNowRunSelector } from '../../../../state/reducers/test_now_runs'; import { clearTestNowMonitorAction } from '../../../../state/actions'; +import { StatusBadge } from './status_badge'; interface MonitorListStatusColumnProps { configId?: string; monitorId?: string; + checkGroup?: string; status: string; monitorType: string; timestamp: string; duration?: number; summaryPings: Ping[]; + summaryError?: PingError; } const StatusColumnFlexG = styled(EuiFlexGroup)` @@ -167,15 +168,13 @@ export const MonitorListStatusColumn = ({ monitorId, status, duration, + checkGroup, + summaryError, summaryPings = [], timestamp: tsString, }: MonitorListStatusColumnProps) => { const timestamp = parseTimestamp(tsString); - const { - colors: { dangerBehindText }, - } = useContext(UptimeThemeContext); - const { statusMessage, locTooltip } = getLocationStatus(summaryPings, status); const dispatch = useDispatch(); @@ -204,12 +203,12 @@ export const MonitorListStatusColumn = ({ stopProgressTrack={stopProgressTrack} /> ) : ( - - {getHealthMessage(status)} - + )} diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.test.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.test.tsx new file mode 100644 index 0000000000000..992defffc5552 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { StatusBadge } from './status_badge'; +import { render } from '../../../../lib/helper/rtl_helpers'; + +describe('', () => { + it('render no error for up status', () => { + render(); + + expect(screen.getByText('Up')).toBeInTheDocument(); + }); + + it('renders errors for downs state', () => { + render( + + ); + + expect(screen.getByText('Down')).toBeInTheDocument(); + expect( + screen.getByLabelText('journey did not run. Click for more details.') + ).toBeInTheDocument(); + }); + + it('renders errors for downs state for http monitor', () => { + render( + + ); + + expect(screen.getByText('Down')).toBeInTheDocument(); + expect(screen.getByLabelText('journey did not run')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.tsx new file mode 100644 index 0000000000000..fe2c7730275db --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBadge, EuiToolTip } from '@elastic/eui'; +import React, { useContext, useState } from 'react'; +import { STATUS } from '../../../../../common/constants'; +import { getHealthMessage } from './monitor_status_column'; +import { UptimeThemeContext } from '../../../../contexts'; +import { PingError } from '../../../../../common/runtime_types'; +import { getInlineErrorLabel } from '../../../monitor_management/monitor_list/inline_error'; +import { StdErrorPopover } from '../../../monitor_management/monitor_list/stderr_logs_popover'; + +export const StatusBadge = ({ + status, + checkGroup, + summaryError, + monitorType, +}: { + status: string; + monitorType: string; + checkGroup?: string; + summaryError?: PingError; +}) => { + const { + colors: { dangerBehindText }, + } = useContext(UptimeThemeContext); + const [isOpen, setIsOpen] = useState(false); + + if (status === STATUS.UP) { + return ( + + {getHealthMessage(status)} + + ); + } + + const errorMessage = + monitorType !== 'browser' ? summaryError?.message : getInlineErrorLabel(summaryError?.message); + + const button = ( + + setIsOpen(true)} + onClickAriaLabel={errorMessage} + > + {getHealthMessage(status)} + + + ); + + if (monitorType !== 'browser') { + return button; + } + + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx index a2d823cd90af1..552256a6aff1a 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx @@ -123,7 +123,8 @@ export const MonitorListComponent: ({ state: { timestamp, summaryPings, - monitor: { type, duration }, + monitor: { type, duration, checkGroup }, + error: summaryError, }, configId, }: MonitorSummary @@ -137,6 +138,8 @@ export const MonitorListComponent: ({ monitorType={type} duration={duration?.us} monitorId={monitorId} + checkGroup={checkGroup} + summaryError={summaryError} /> ); }, diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/stderr_logs.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/stderr_logs.tsx new file mode 100644 index 0000000000000..cef4ff550a23d --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/stderr_logs.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBasicTableColumn, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiHighlight, + EuiLink, + EuiSpacer, + EuiTitle, + formatDate, +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiInMemoryTable } from '@elastic/eui'; +import moment from 'moment'; +import { useSelector } from 'react-redux'; +import { useStdErrorLogs } from './use_std_error_logs'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { ClientPluginsStart } from '../../../apps/plugin'; +import { useFetcher } from '../../../../../observability/public'; +import { selectDynamicSettings } from '../../../state/selectors'; +import { Ping } from '../../../../common/runtime_types'; + +export const StdErrorLogs = ({ + configId, + checkGroup, + timestamp, + title, + summaryMessage, +}: { + configId?: string; + checkGroup?: string; + timestamp?: string; + title?: string; + summaryMessage?: string; +}) => { + const columns = [ + { + field: '@timestamp', + name: TIMESTAMP_LABEL, + sortable: true, + render: (date: string) => formatDate(date, 'dateTime'), + }, + { + field: 'synthetics.payload.message', + name: 'Message', + render: (message: string) => ( + + {message} + + ), + }, + ] as Array>; + + const { items, loading } = useStdErrorLogs({ configId, checkGroup }); + + const { discover, observability } = useKibana().services; + + const { settings } = useSelector(selectDynamicSettings); + + const { data: discoverLink } = useFetcher(async () => { + if (settings?.heartbeatIndices) { + const dataView = await observability.getAppDataView('synthetics', settings?.heartbeatIndices); + return discover.locator?.getUrl({ + query: { language: 'kuery', query: `monitor.check_group: ${checkGroup}` }, + indexPatternId: dataView?.id, + columns: ['synthetics.payload.message', 'error.message'], + timeRange: timestamp + ? { + from: moment(timestamp).subtract(10, 'minutes').toISOString(), + to: moment(timestamp).add(5, 'minutes').toISOString(), + } + : undefined, + }); + } + return ''; + }, [checkGroup, timestamp]); + + const search = { + box: { + incremental: true, + }, + }; + + return ( + <> + + + +

{title ?? TEST_RUN_LOGS_LABEL}

+
+
+ + + + {VIEW_IN_DISCOVER_LABEL} + + + +
+ + +

{summaryMessage}

+
+ + + + + + ); +}; + +export const TIMESTAMP_LABEL = i18n.translate('xpack.uptime.monitorList.timestamp', { + defaultMessage: 'Timestamp', +}); + +export const ERROR_SUMMARY_LABEL = i18n.translate('xpack.uptime.monitorList.errorSummary', { + defaultMessage: 'Error summary', +}); + +export const VIEW_IN_DISCOVER_LABEL = i18n.translate('xpack.uptime.monitorList.viewInDiscover', { + defaultMessage: 'View in discover', +}); + +export const TEST_RUN_LOGS_LABEL = i18n.translate('xpack.uptime.monitorList.testRunLogs', { + defaultMessage: 'Test run logs', +}); diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_std_error_logs.ts b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_std_error_logs.ts new file mode 100644 index 0000000000000..fa563b2ef2728 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_std_error_logs.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSelector } from 'react-redux'; +import { createEsParams, useEsSearch } from '../../../../../observability/public'; +import { selectDynamicSettings } from '../../../state/selectors'; +import { Ping } from '../../../../common/runtime_types'; + +export const useStdErrorLogs = ({ + configId, + checkGroup, +}: { + configId?: string; + checkGroup?: string; +}) => { + const { settings } = useSelector(selectDynamicSettings); + const { data, loading } = useEsSearch( + createEsParams({ + index: !configId && !checkGroup ? '' : settings?.heartbeatIndices, + body: { + size: 1000, + query: { + bool: { + filter: [ + { + term: { + 'synthetics.type': 'stderr', + }, + }, + ...(configId + ? [ + { + term: { + config_id: configId, + }, + }, + ] + : []), + ...(checkGroup + ? [ + { + term: { + 'monitor.check_group': checkGroup, + }, + }, + ] + : []), + ], + }, + }, + }, + }), + [settings?.heartbeatIndices], + { name: 'getStdErrLogs' } + ); + + return { + items: data?.hits.hits.map((hit) => ({ ...(hit._source as Ping), id: hit._id })) ?? [], + loading, + }; +}; diff --git a/x-pack/plugins/uptime/public/contexts/uptime_refresh_context.tsx b/x-pack/plugins/uptime/public/contexts/uptime_refresh_context.tsx index 7f81628129d3e..12d904ae3c4b5 100644 --- a/x-pack/plugins/uptime/public/contexts/uptime_refresh_context.tsx +++ b/x-pack/plugins/uptime/public/contexts/uptime_refresh_context.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { createContext, useMemo, useState } from 'react'; +import React, { createContext, useContext, useMemo, useState } from 'react'; interface UptimeRefreshContext { lastRefresh: number; @@ -35,3 +35,5 @@ export const UptimeRefreshContextProvider: React.FC = ({ children }) => { return ; }; + +export const useUptimeRefreshContext = () => useContext(UptimeRefreshContext); diff --git a/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx b/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx index 63f21a23e30d3..67058be9a9d65 100644 --- a/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx +++ b/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx @@ -21,6 +21,7 @@ export interface UptimeSettingsContextValues { isLogsAvailable: boolean; config: UptimeUiConfig; commonlyUsedRanges?: CommonlyUsedRange[]; + isDev?: boolean; } const { BASE_PATH } = CONTEXT_DEFAULTS; @@ -39,6 +40,7 @@ const defaultContext: UptimeSettingsContextValues = { isInfraAvailable: true, isLogsAvailable: true, config: {}, + isDev: false, }; export const UptimeSettingsContext = createContext(defaultContext); @@ -50,12 +52,14 @@ export const UptimeSettingsContextProvider: React.FC = ({ childr isLogsAvailable, commonlyUsedRanges, config, + isDev, } = props; const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); const value = useMemo(() => { return { + isDev, basePath, isApmAvailable, isInfraAvailable, @@ -66,6 +70,7 @@ export const UptimeSettingsContextProvider: React.FC = ({ childr dateRangeEnd: dateRangeEnd ?? DATE_RANGE_END, }; }, [ + isDev, basePath, isApmAvailable, isInfraAvailable, diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx index 0ad9dbd6b06e7..d826db82517fc 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx @@ -7,15 +7,18 @@ import React, { useEffect, useReducer, useCallback, Reducer } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; import { useTrackPageview } from '../../../../observability/public'; import { ConfigKey } from '../../../common/runtime_types'; import { getMonitors } from '../../state/actions'; import { monitorManagementListSelector } from '../../state/selectors'; -import { - MonitorManagementList, - MonitorManagementListPageState, -} from '../../components/monitor_management/monitor_list/monitor_list'; +import { MonitorManagementListPageState } from '../../components/monitor_management/monitor_list/monitor_list'; import { useMonitorManagementBreadcrumbs } from './use_monitor_management_breadcrumbs'; +import { useInlineErrors } from '../../components/monitor_management/hooks/use_inline_errors'; +import { MonitorListTabs } from '../../components/monitor_management/monitor_list/list_tabs'; +import { AllMonitors } from '../../components/monitor_management/monitor_list/all_monitors'; +import { InvalidMonitors } from '../../components/monitor_management/monitor_list/invalid_monitors'; +import { useInvalidMonitors } from '../../components/monitor_management/hooks/use_invalid_monitors'; export const MonitorManagementPage: React.FC = () => { const [pageState, dispatchPageAction] = useReducer( @@ -47,17 +50,48 @@ export const MonitorManagementPage: React.FC = () => { const { pageIndex, pageSize, sortField, sortOrder } = pageState as MonitorManagementListPageState; + const { type: viewType } = useParams<{ type: 'all' | 'invalid' }>(); + const { errorSummaries, loading, count } = useInlineErrors({ + onlyInvalidMonitors: viewType === 'invalid', + sortField: pageState.sortField, + sortOrder: pageState.sortOrder, + }); + useEffect(() => { - dispatch(getMonitors({ page: pageIndex, perPage: pageSize, sortField, sortOrder })); - }, [dispatch, pageState, pageIndex, pageSize, sortField, sortOrder]); + if (viewType === 'all') { + dispatch(getMonitors({ page: pageIndex, perPage: pageSize, sortField, sortOrder })); + } + }, [dispatch, pageState, pageIndex, pageSize, sortField, sortOrder, viewType]); + + const { data: monitorSavedObjects, loading: objectsLoading } = useInvalidMonitors(errorSummaries); return ( - + <> + + {viewType === 'all' ? ( + + ) : ( + + )} + ); }; diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx index e5784591a00fc..834752c996153 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx @@ -25,7 +25,8 @@ export const useMonitorManagementBreadcrumbs = ({ useBreadcrumbs([ { text: MONITOR_MANAGEMENT_CRUMB, - href: isAddMonitor || isEditMonitor ? `${appPath}/${MONITOR_MANAGEMENT_ROUTE}` : undefined, + href: + isAddMonitor || isEditMonitor ? `${appPath}/${MONITOR_MANAGEMENT_ROUTE}/all` : undefined, }, ...(isAddMonitor ? [ diff --git a/x-pack/plugins/uptime/public/routes.tsx b/x-pack/plugins/uptime/public/routes.tsx index 5d7e0a46a29d3..e68f25fcbb134 100644 --- a/x-pack/plugins/uptime/public/routes.tsx +++ b/x-pack/plugins/uptime/public/routes.tsx @@ -237,7 +237,7 @@ const getRoutes = (config: UptimeConfig, canSave: boolean): RouteProps[] => { defaultMessage: 'Manage Monitors | {baseTitle}', values: { baseTitle }, }), - path: MONITOR_MANAGEMENT_ROUTE, + path: MONITOR_MANAGEMENT_ROUTE + '/:type', component: MonitorManagementPage, dataTestSubj: 'uptimeMonitorManagementListPage', telemetryId: UptimePage.MonitorManagement, diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index d9dadc81397ce..fb5c0cd1e69a1 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -62,6 +62,7 @@ export interface UptimeServerSetup { telemetry: TelemetryEventsSender; uptimeEsClient: UptimeESClient; basePath: IBasePath; + isDev?: boolean; } export interface UptimeCorePluginsSetup { diff --git a/x-pack/plugins/uptime/server/lib/lib.ts b/x-pack/plugins/uptime/server/lib/lib.ts index f61497816e2d9..220ac5a3797a4 100644 --- a/x-pack/plugins/uptime/server/lib/lib.ts +++ b/x-pack/plugins/uptime/server/lib/lib.ts @@ -49,7 +49,9 @@ export function createUptimeESClient({ esClient, request, savedObjectsClient, + isInspectorEnabled, }: { + isInspectorEnabled?: boolean; esClient: ElasticsearchClient; request?: KibanaRequest; savedObjectsClient: SavedObjectsClientContract; @@ -94,7 +96,7 @@ export function createUptimeESClient({ startTime: startTimeNow, }) ); - if (request) { + if (request && isInspectorEnabled) { debugESCall({ startTime, request, esError, operationName: 'search', params: esParams }); } } @@ -123,7 +125,7 @@ export function createUptimeESClient({ } const inspectableEsQueries = inspectableEsQueriesMap.get(request!); - if (inspectableEsQueries && request) { + if (inspectableEsQueries && request && isInspectorEnabled) { debugESCall({ startTime, request, esError, operationName: 'count', params: esParams }); } diff --git a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts index 5a714fd2514d8..6359a122638f2 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts @@ -90,6 +90,7 @@ export const summaryPingsToSummary = (summaryPings: Ping[]): MonitorSummary => { name: latest.monitor?.name, type: latest.monitor?.type, duration: latest.monitor?.duration, + checkGroup: latest.monitor?.check_group, }, url: latest.url ?? {}, summary: { @@ -104,6 +105,7 @@ export const summaryPingsToSummary = (summaryPings: Ping[]): MonitorSummary => { geo: { name: summaryPings.map((p) => p.observer?.geo?.name ?? '').filter((n) => n !== '') }, }, service: summaryPings.find((p) => p.service?.name)?.service, + error: latest.error, }, }; }; diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts index 3d132e74d24d5..f240652b27691 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts @@ -43,11 +43,14 @@ export const hydrateSavedObjects = async ({ missingInfoIds ); - const updatedObjects = monitors + const updatedObjects: SyntheticsMonitorSavedObject[] = []; + monitors .filter((monitor) => missingInfoIds.includes(monitor.id)) - .map((monitor) => { + .forEach((monitor) => { let resultAttributes: Partial = monitor.attributes; + let isUpdated = false; + esDocs.forEach((doc) => { // to make sure the document is ingested after the latest update of the monitor const documentIsAfterLatestUpdate = moment(monitor.updated_at).isBefore( @@ -57,15 +60,21 @@ export const hydrateSavedObjects = async ({ if (doc.config_id !== monitor.id) return monitor; if (doc.url?.full) { + isUpdated = true; resultAttributes = { ...resultAttributes, urls: doc.url?.full }; } if (doc.url?.port) { + isUpdated = true; resultAttributes = { ...resultAttributes, ['url.port']: doc.url?.port }; } }); - - return { ...monitor, attributes: resultAttributes }; + if (isUpdated) { + updatedObjects.push({ + ...monitor, + attributes: resultAttributes, + } as SyntheticsMonitorSavedObject); + } }); await server.authSavedObjectsClient?.bulkUpdate(updatedObjects); diff --git a/x-pack/plugins/uptime/server/plugin.ts b/x-pack/plugins/uptime/server/plugin.ts index 2f329aa83a5c4..61272651e1ce2 100644 --- a/x-pack/plugins/uptime/server/plugin.ts +++ b/x-pack/plugins/uptime/server/plugin.ts @@ -81,6 +81,7 @@ export class Plugin implements PluginType { basePath: core.http.basePath, logger: this.logger, telemetry: this.telemetryEventsSender, + isDev: this.initContext.env.mode.dev, } as UptimeServerSetup; if (this.isServiceEnabled && this.server.config.service) { diff --git a/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts b/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts index cf03e7d58fd14..60ba60087382a 100644 --- a/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts +++ b/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts @@ -41,12 +41,13 @@ export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute, server) => const uptimeEsClient = createUptimeESClient({ request, savedObjectsClient, + isInspectorEnabled, esClient: esClient.asCurrentUser, }); server.uptimeEsClient = uptimeEsClient; - if (isInspectorEnabled) { + if (isInspectorEnabled || server.isDev) { inspectableEsQueriesMap.set(request, []); } @@ -66,7 +67,7 @@ export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute, server) => return response.ok({ body: { ...res, - ...(isInspectorEnabled && uptimeRoute.path !== API_URLS.DYNAMIC_SETTINGS + ...((isInspectorEnabled || server.isDev) && uptimeRoute.path !== API_URLS.DYNAMIC_SETTINGS ? { _inspect: inspectableEsQueriesMap.get(request) } : {}), }, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts index b731102ad672b..588e7132f268c 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts @@ -23,7 +23,6 @@ export default function createAggregateTests({ getService }: FtrProviderContext) const response = await supertest.get( `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_aggregate` ); - expect(response.status).to.eql(200); expect(response.body).to.eql({ rule_enabled_status: { @@ -42,6 +41,9 @@ export default function createAggregateTests({ getService }: FtrProviderContext) muted: 0, unmuted: 0, }, + rule_snoozed_status: { + snoozed: 0, + }, }); }); @@ -96,12 +98,11 @@ export default function createAggregateTests({ getService }: FtrProviderContext) // calls are successful, the call to aggregate may return stale totals if called // too early. await delay(1000); - const reponse = await supertest.get( + const response = await supertest.get( `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_aggregate` ); - - expect(reponse.status).to.eql(200); - expect(reponse.body).to.eql({ + expect(response.status).to.eql(200); + expect(response.body).to.eql({ rule_enabled_status: { disabled: 0, enabled: 7, @@ -118,6 +119,9 @@ export default function createAggregateTests({ getService }: FtrProviderContext) muted: 0, unmuted: 7, }, + rule_snoozed_status: { + snoozed: 0, + }, }); }); @@ -195,6 +199,9 @@ export default function createAggregateTests({ getService }: FtrProviderContext) muted: 0, unmuted: 7, }, + ruleSnoozedStatus: { + snoozed: 0, + }, }); }); }); diff --git a/x-pack/test/api_integration/apis/transform/delete_transforms.ts b/x-pack/test/api_integration/apis/transform/delete_transforms.ts index b823c46509a63..7f14081e5c574 100644 --- a/x-pack/test/api_integration/apis/transform/delete_transforms.ts +++ b/x-pack/test/api_integration/apis/transform/delete_transforms.ts @@ -66,7 +66,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndex); }); @@ -148,7 +148,7 @@ export default ({ getService }: FtrProviderContext) => { async ({ id: transformId }: { id: string }, idx: number) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndices[idx]); } @@ -178,7 +178,7 @@ export default ({ getService }: FtrProviderContext) => { async ({ id: transformId }: { id: string }, idx: number) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndices[idx]); } @@ -219,13 +219,13 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(true); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesNotToExist(destinationIndex); }); }); - describe('with deleteDestIndexPattern setting', function () { + describe('with deleteDestDataView setting', function () { const transformId = 'test3'; const destinationIndex = generateDestIndex(transformId); @@ -244,7 +244,7 @@ export default ({ getService }: FtrProviderContext) => { const reqBody: DeleteTransformsRequestSchema = { transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], deleteDestIndex: false, - deleteDestIndexPattern: true, + deleteDestDataView: true, }; const { body, status } = await supertest .post(`/api/transform/delete_transforms`) @@ -258,14 +258,14 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(true); + expect(body[transformId].destDataViewDeleted.success).to.eql(true); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndex); await transform.testResources.assertIndexPatternNotExist(destinationIndex); }); }); - describe('with deleteDestIndex & deleteDestIndexPattern setting', function () { + describe('with deleteDestIndex & deleteDestDataView setting', function () { const transformId = 'test4'; const destinationIndex = generateDestIndex(transformId); @@ -284,7 +284,7 @@ export default ({ getService }: FtrProviderContext) => { const reqBody: DeleteTransformsRequestSchema = { transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], deleteDestIndex: true, - deleteDestIndexPattern: true, + deleteDestDataView: true, }; const { body, status } = await supertest .post(`/api/transform/delete_transforms`) @@ -298,7 +298,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(true); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(true); + expect(body[transformId].destDataViewDeleted.success).to.eql(true); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesNotToExist(destinationIndex); await transform.testResources.assertIndexPatternNotExist(destinationIndex); diff --git a/x-pack/test/common/services/bsearch_secure.ts b/x-pack/test/common/services/bsearch_secure.ts index 622cca92aead5..c1aa173280f54 100644 --- a/x-pack/test/common/services/bsearch_secure.ts +++ b/x-pack/test/common/services/bsearch_secure.ts @@ -29,6 +29,8 @@ const getSpaceUrlPrefix = (spaceId?: string): string => { interface SendOptions { supertestWithoutAuth: SuperTest.SuperTest; auth: { username: string; password: string }; + referer?: string; + kibanaVersion?: string; options: object; strategy: string; space?: string; @@ -38,17 +40,45 @@ export const BSecureSearchFactory = (retry: RetryService) => ({ send: async ({ supertestWithoutAuth, auth, + referer, + kibanaVersion, options, strategy, space, }: SendOptions): Promise => { const spaceUrl = getSpaceUrlPrefix(space); const { body } = await retry.try(async () => { - const result = await supertestWithoutAuth - .post(`${spaceUrl}/internal/search/${strategy}`) - .auth(auth.username, auth.password) - .set('kbn-xsrf', 'true') - .send(options); + let result; + const url = `${spaceUrl}/internal/search/${strategy}`; + if (referer && kibanaVersion) { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('referer', referer) + .set('kbn-version', kibanaVersion) + .set('kbn-xsrf', 'true') + .send(options); + } else if (referer) { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('referer', referer) + .set('kbn-xsrf', 'true') + .send(options); + } else if (kibanaVersion) { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('kbn-version', kibanaVersion) + .set('kbn-xsrf', 'true') + .send(options); + } else { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('kbn-xsrf', 'true') + .send(options); + } if (result.status === 500 || result.status === 200) { return result; } diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index f5c066f61db1c..278018796d10a 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -493,7 +493,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - describe('indicator enrichment', () => { + describe('indicator enrichment: threat-first search', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/filebeat/threat_intel'); }); @@ -513,7 +513,440 @@ export default ({ getService }: FtrProviderContext) => { language: 'kuery', rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', - query: '*:*', + query: '*:*', // narrow events down to 2 with a destination.ip + threat_indicator_path: 'threat.indicator', + threat_query: 'threat.indicator.domain: 159.89.119.67', // narrow things down to indicators with a domain + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.domain', + field: 'destination.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 2, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).equal(2); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source?.threat); + expect(threats).to.eql([ + { + enrichments: [ + { + feed: {}, + indicator: { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + provider: 'geenensp', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + type: 'url', + }, + matched: { + atomic: '159.89.119.67', + id: '978783', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'destination.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ], + }, + { + enrichments: [ + { + feed: {}, + indicator: { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + provider: 'geenensp', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + type: 'url', + }, + matched: { + atomic: '159.89.119.67', + id: '978783', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'destination.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ], + }, + ]); + }); + + it('enriches signals with multiple indicators if several matched', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'NOT source.port:35326', // specify query to have signals more than treat indicators, but only 1 will match + threat_indicator_path: 'threat.indicator', + threat_query: 'threat.indicator.ip: *', + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + 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).equal(1); + + const { hits } = signalsOpen.hits; + const [threat] = hits.map((hit) => hit._source?.threat) as Array<{ + enrichments: unknown[]; + }>; + + assertContains(threat.enrichments, [ + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + matched: { + atomic: '45.115.45.3', + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + provider: 'other_provider', + type: 'ip', + }, + + matched: { + atomic: '45.115.45.3', + id: '978787', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ]); + }); + + it('adds a single indicator that matched multiple fields', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'NOT source.port:35326', // specify query to have signals more than treat indicators, but only 1 will match + threat_indicator_path: 'threat.indicator', + threat_query: 'threat.indicator.port: 57324 or threat.indicator.ip:45.115.45.3', // narrow our query to a single indicator + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.port', + field: 'source.port', + type: 'mapping', + }, + ], + }, + { + entries: [ + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + 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).equal(1); + + const { hits } = signalsOpen.hits; + const [threat] = hits.map((hit) => hit._source?.threat) as Array<{ + enrichments: unknown[]; + }>; + + assertContains(threat.enrichments, [ + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + matched: { + atomic: '45.115.45.3', + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + // We do not merge matched indicators during enrichment, so in + // certain circumstances a given indicator document could appear + // multiple times in an enriched alert (albeit with different + // threat.indicator.matched data). That's the case with the + // first and third indicators matched, here. + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + + matched: { + atomic: 57324, + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.port', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + provider: 'other_provider', + type: 'ip', + }, + matched: { + atomic: '45.115.45.3', + id: '978787', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ]); + }); + + it('generates multiple signals with multiple matches', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + threat_language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: '*:*', // narrow our query to a single record that matches two indicators + threat_indicator_path: 'threat.indicator', + threat_query: '*:*', + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.port', + field: 'source.port', + type: 'mapping', + }, + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + { + entries: [ + { + value: 'threat.indicator.domain', + field: 'destination.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 2, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).equal(2); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source?.threat) as Array<{ + enrichments: unknown[]; + }>; + + assertContains(threats[0].enrichments, [ + { + feed: {}, + indicator: { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + matched: { + atomic: '159.89.119.67', + id: '978783', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'destination.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + matched: { + atomic: '45.115.45.3', + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + matched: { + atomic: 57324, + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.port', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ]); + + assertContains(threats[1].enrichments, [ + { + feed: {}, + indicator: { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + matched: { + atomic: '159.89.119.67', + id: '978783', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'destination.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ]); + }); + }); + + describe('indicator enrichment: event-first search', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/filebeat/threat_intel'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/filebeat/threat_intel'); + }); + + it('enriches signals with the single indicator that matched', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'destination.ip:159.89.119.67', threat_indicator_path: 'threat.indicator', threat_query: 'threat.indicator.domain: *', // narrow things down to indicators with a domain threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module @@ -797,7 +1230,7 @@ export default ({ getService }: FtrProviderContext) => { threat_language: 'kuery', rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', - query: '*:*', // narrow our query to a single record that matches two indicators + query: '(source.port:57324 and source.ip:45.115.45.3) or destination.ip:159.89.119.67', // narrow our query to a single record that matches two indicators threat_indicator_path: 'threat.indicator', threat_query: '*:*', threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module diff --git a/x-pack/test/examples/search_examples/index.ts b/x-pack/test/examples/search_examples/index.ts index ac9e385d3d391..18b2acbd56564 100644 --- a/x-pack/test/examples/search_examples/index.ts +++ b/x-pack/test/examples/search_examples/index.ts @@ -33,5 +33,6 @@ export default function ({ getService, loadTestFile }: PluginFunctionalProviderC loadTestFile(require.resolve('./search_example')); loadTestFile(require.resolve('./search_sessions_cache')); loadTestFile(require.resolve('./partial_results_example')); + loadTestFile(require.resolve('./sql_search_example')); }); } diff --git a/x-pack/test/examples/search_examples/sql_search_example.ts b/x-pack/test/examples/search_examples/sql_search_example.ts new file mode 100644 index 0000000000000..a51ea21ea36bd --- /dev/null +++ b/x-pack/test/examples/search_examples/sql_search_example.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common']); + const toasts = getService('toasts'); + + describe('SQL search example', () => { + const appId = 'searchExamples'; + + before(async function () { + await PageObjects.common.navigateToApp(appId, { insertTimestamp: false }); + await testSubjects.click('/sql-search'); + }); + + it('should search', async () => { + const sqlQuery = `SELECT index, bytes FROM "logstash-*" ORDER BY "@timestamp" DESC`; + await (await testSubjects.find('sqlQueryInput')).type(sqlQuery); + + await testSubjects.click(`querySubmitButton`); + + await testSubjects.stringExistsInCodeBlockOrFail( + 'requestCodeBlock', + JSON.stringify(sqlQuery) + ); + await testSubjects.stringExistsInCodeBlockOrFail( + 'responseCodeBlock', + `"logstash-2015.09.22"` + ); + expect(await toasts.getToastCount()).to.be(0); + }); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts index 0e3cd9796626d..6c2c2c7bc8b48 100644 --- a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts +++ b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts @@ -478,6 +478,38 @@ export default function (providerContext: FtrProviderContext) { }); }); + it('should return a 409 if policy already exists with name given', async () => { + const sharedBody = { + name: 'Initial name', + description: 'Initial description', + namespace: 'default', + }; + + await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send(sharedBody) + .expect(200); + + const { body } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send(sharedBody) + .expect(409); + + expect(body.message).to.match(/already exists?/); + + // same name, different namespace + sharedBody.namespace = 'different'; + await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send(sharedBody) + .expect(409); + + expect(body.message).to.match(/already exists?/); + }); + it('sets given is_managed value', async () => { const { body: { item: createdPolicy }, @@ -504,6 +536,7 @@ export default function (providerContext: FtrProviderContext) { name: 'TEST2', namespace: 'default', is_managed: false, + force: true, }) .expect(200); @@ -513,36 +546,33 @@ export default function (providerContext: FtrProviderContext) { expect(policy2.is_managed).to.equal(false); }); - it('should return a 409 if policy already exists with name given', async () => { - const sharedBody = { - name: 'Initial name', - description: 'Initial description', - namespace: 'default', - }; - - await supertest + it('should return a 400 if trying to update a managed policy', async () => { + const { + body: { item: originalPolicy }, + } = await supertest .post(`/api/fleet/agent_policies`) .set('kbn-xsrf', 'xxxx') - .send(sharedBody) + .send({ + name: `Managed policy ${Date.now()}`, + description: 'Initial description', + namespace: 'default', + is_managed: true, + }) .expect(200); const { body } = await supertest - .post(`/api/fleet/agent_policies`) + .put(`/api/fleet/agent_policies/${originalPolicy.id}`) .set('kbn-xsrf', 'xxxx') - .send(sharedBody) - .expect(409); - - expect(body.message).to.match(/already exists?/); - - // same name, different namespace - sharedBody.namespace = 'different'; - await supertest - .post(`/api/fleet/agent_policies`) - .set('kbn-xsrf', 'xxxx') - .send(sharedBody) - .expect(409); + .send({ + name: 'Updated name', + description: 'Initial description', + namespace: 'default', + }) + .expect(400); - expect(body.message).to.match(/already exists?/); + expect(body.message).to.equal( + 'Cannot update name in Fleet because the agent policy is managed by an external orchestration solution, such as Elastic Cloud, Kubernetes, etc. Please make changes using your orchestration solution.' + ); }); }); @@ -586,6 +616,7 @@ export default function (providerContext: FtrProviderContext) { name: 'Regular policy', namespace: 'default', is_managed: false, + force: true, }) .expect(200); diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index a44b8be478874..82b19cb02faf8 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -447,6 +447,11 @@ const expectAssetsInstalled = ({ id: 'sample_security_rule', }); expect(resSecurityRule.id).equal('sample_security_rule'); + const resCloudSecurityPostureRuleTemplate = await kibanaServer.savedObjects.get({ + type: 'csp-rule-template', + id: 'sample_csp_rule_template', + }); + expect(resCloudSecurityPostureRuleTemplate.id).equal('sample_csp_rule_template'); const resTag = await kibanaServer.savedObjects.get({ type: 'tag', id: 'sample_tag', @@ -496,8 +501,11 @@ const expectAssetsInstalled = ({ package_assets: sortBy(res.attributes.package_assets, (o: AssetReference) => o.type), }; expect(sortedRes).eql({ - installed_kibana_space_id: 'default', installed_kibana: [ + { + id: 'sample_csp_rule_template', + type: 'csp-rule-template', + }, { id: 'sample_dashboard', type: 'dashboard', @@ -535,6 +543,7 @@ const expectAssetsInstalled = ({ type: 'visualization', }, ], + installed_kibana_space_id: 'default', installed_es: [ { id: 'logs-all_assets.test_logs@mappings', @@ -593,37 +602,116 @@ const expectAssetsInstalled = ({ type: 'ml_model', }, ], + package_assets: [ + { + id: '333a22a1-e639-5af5-ae62-907ffc83d603', + type: 'epm-packages-assets', + }, + { + id: '256f3dad-6870-56c3-80a1-8dfa11e2d568', + type: 'epm-packages-assets', + }, + { + id: '3fa0512f-bc01-5c2e-9df1-bc2f2a8259c8', + type: 'epm-packages-assets', + }, + { + id: 'ea334ad8-80c2-5acd-934b-2a377290bf97', + type: 'epm-packages-assets', + }, + { + id: '96c6eb85-fe2e-56c6-84be-5fda976796db', + type: 'epm-packages-assets', + }, + { + id: '2d73a161-fa69-52d0-aa09-1bdc691b95bb', + type: 'epm-packages-assets', + }, + { + id: '0a00c2d2-ce63-5b9c-9aa0-0cf1938f7362', + type: 'epm-packages-assets', + }, + { + id: '691f0505-18c5-57a6-9f40-06e8affbdf7a', + type: 'epm-packages-assets', + }, + { + id: 'b36e6dd0-58f7-5dd0-a286-8187e4019274', + type: 'epm-packages-assets', + }, + { + id: 'f839c76e-d194-555a-90a1-3265a45789e4', + type: 'epm-packages-assets', + }, + { + id: '9af7bbb3-7d8a-50fa-acc9-9dde6f5efca2', + type: 'epm-packages-assets', + }, + { + id: '1e97a20f-9d1c-529b-8ff2-da4e8ba8bb71', + type: 'epm-packages-assets', + }, + { + id: 'ed5d54d5-2516-5d49-9e61-9508b0152d2b', + type: 'epm-packages-assets', + }, + { + id: 'bd5ff3c5-655e-5385-9918-b60ff3040aad', + type: 'epm-packages-assets', + }, + { + id: '943d5767-41f5-57c3-ba02-48e0f6a837db', + type: 'epm-packages-assets', + }, + { + id: '0954ce3b-3165-5c1f-a4c0-56eb5f2fa487', + type: 'epm-packages-assets', + }, + { + id: '60d6d054-57e4-590f-a580-52bf3f5e7cca', + type: 'epm-packages-assets', + }, + { + id: '47758dc2-979d-5fbe-a2bd-9eded68a5a43', + type: 'epm-packages-assets', + }, + { + id: '318959c9-997b-5a14-b328-9fc7355b4b74', + type: 'epm-packages-assets', + }, + { + id: 'e21b59b5-eb76-5ab0-bef2-1c8e379e6197', + type: 'epm-packages-assets', + }, + { + id: '4c758d70-ecf1-56b3-b704-6d8374841b34', + type: 'epm-packages-assets', + }, + { + id: 'e786cbd9-0f3b-5a0b-82a6-db25145ebf58', + type: 'epm-packages-assets', + }, + { + id: 'd8b175c3-0d42-5ec7-90c1-d1e4b307a4c2', + type: 'epm-packages-assets', + }, + { + id: 'b265a5e0-c00b-5eda-ac44-2ddbd36d9ad0', + type: 'epm-packages-assets', + }, + { + id: '53c94591-aa33-591d-8200-cd524c2a0561', + type: 'epm-packages-assets', + }, + { + id: 'b658d2d4-752e-54b8-afc2-4c76155c1466', + type: 'epm-packages-assets', + }, + ], es_index_patterns: { test_logs: 'logs-all_assets.test_logs-*', test_metrics: 'metrics-all_assets.test_metrics-*', }, - package_assets: [ - { id: '333a22a1-e639-5af5-ae62-907ffc83d603', type: 'epm-packages-assets' }, - { id: '256f3dad-6870-56c3-80a1-8dfa11e2d568', type: 'epm-packages-assets' }, - { id: '3fa0512f-bc01-5c2e-9df1-bc2f2a8259c8', type: 'epm-packages-assets' }, - { id: 'ea334ad8-80c2-5acd-934b-2a377290bf97', type: 'epm-packages-assets' }, - { id: '96c6eb85-fe2e-56c6-84be-5fda976796db', type: 'epm-packages-assets' }, - { id: '2d73a161-fa69-52d0-aa09-1bdc691b95bb', type: 'epm-packages-assets' }, - { id: '0a00c2d2-ce63-5b9c-9aa0-0cf1938f7362', type: 'epm-packages-assets' }, - { id: '691f0505-18c5-57a6-9f40-06e8affbdf7a', type: 'epm-packages-assets' }, - { id: 'b36e6dd0-58f7-5dd0-a286-8187e4019274', type: 'epm-packages-assets' }, - { id: 'f839c76e-d194-555a-90a1-3265a45789e4', type: 'epm-packages-assets' }, - { id: '9af7bbb3-7d8a-50fa-acc9-9dde6f5efca2', type: 'epm-packages-assets' }, - { id: '1e97a20f-9d1c-529b-8ff2-da4e8ba8bb71', type: 'epm-packages-assets' }, - { id: 'ed5d54d5-2516-5d49-9e61-9508b0152d2b', type: 'epm-packages-assets' }, - { id: 'bd5ff3c5-655e-5385-9918-b60ff3040aad', type: 'epm-packages-assets' }, - { id: '0954ce3b-3165-5c1f-a4c0-56eb5f2fa487', type: 'epm-packages-assets' }, - { id: '60d6d054-57e4-590f-a580-52bf3f5e7cca', type: 'epm-packages-assets' }, - { id: '47758dc2-979d-5fbe-a2bd-9eded68a5a43', type: 'epm-packages-assets' }, - { id: '318959c9-997b-5a14-b328-9fc7355b4b74', type: 'epm-packages-assets' }, - { id: 'e21b59b5-eb76-5ab0-bef2-1c8e379e6197', type: 'epm-packages-assets' }, - { id: '4c758d70-ecf1-56b3-b704-6d8374841b34', type: 'epm-packages-assets' }, - { id: 'e786cbd9-0f3b-5a0b-82a6-db25145ebf58', type: 'epm-packages-assets' }, - { id: 'd8b175c3-0d42-5ec7-90c1-d1e4b307a4c2', type: 'epm-packages-assets' }, - { id: 'b265a5e0-c00b-5eda-ac44-2ddbd36d9ad0', type: 'epm-packages-assets' }, - { id: '53c94591-aa33-591d-8200-cd524c2a0561', type: 'epm-packages-assets' }, - { id: 'b658d2d4-752e-54b8-afc2-4c76155c1466', type: 'epm-packages-assets' }, - ], name: 'all_assets', version: '0.1.0', removable: true, diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index 7d28b04c28a53..844a6abe3da06 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -393,6 +393,10 @@ export default function (providerContext: FtrProviderContext) { id: 'sample_security_rule', type: 'security-rule', }, + { + id: 'sample_csp_rule_template2', + type: 'csp-rule-template', + }, { id: 'sample_ml_module', type: 'ml-module', @@ -488,6 +492,7 @@ export default function (providerContext: FtrProviderContext) { { id: '5c3aa147-089c-5084-beca-53c00e72ac80', type: 'epm-packages-assets' }, { id: '0c8c3c6a-90cb-5f0e-8359-d807785b046c', type: 'epm-packages-assets' }, { id: '48e582df-b1d2-5f88-b6ea-ba1fafd3a569', type: 'epm-packages-assets' }, + { id: '7f97600c-d983-53e0-ae2a-a59bf35d7f0d', type: 'epm-packages-assets' }, { id: 'bf3b0b65-9fdc-53c6-a9ca-e76140e56490', type: 'epm-packages-assets' }, { id: '7f4c5aca-b4f5-5f0a-95af-051da37513fc', type: 'epm-packages-assets' }, { id: '4281a436-45a8-54ab-9724-fda6849f789d', type: 'epm-packages-assets' }, diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/csp_rule_template/sample_csp_rule_template.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/csp_rule_template/sample_csp_rule_template.json new file mode 100644 index 0000000000000..cdcd06876e010 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/csp_rule_template/sample_csp_rule_template.json @@ -0,0 +1,17 @@ +{ + "attributes": { + "benchmark": { "name": "CIS Kubernetes", "version": "1.4.1" }, + "benchmark_rule_id": "1.1.1", + "default_value": "By default, anonymous access is enabled.", + "description": "'Disable anonymous requests to the API server", + "impact": "Anonymous requests will be rejected.", + "name": "Ensure that the API server pod specification file permissions are set to 644 or more restrictive (Automated)", + "rationale": "When enabled, requests that are not rejected by other configured authentication methods\nare treated as anonymous requests. These requests are then served by the API server. You\nshould rely on authentication to authorize access and disallow anonymous requests.\nIf you are using RBAC authorization, it is generally considered reasonable to allow\nanonymous access to the API Server for health checks and discovery purposes, and hence\nthis recommendation is not scored. However, you should consider whether anonymous\ndiscovery is an acceptable risk for your purposes.", + "rego_rule_id": "cis_k8s.cis_1_1_1", + "remediation": "Edit the API server pod specification file /etc/kubernetes/manifests/kubeapiserver.yaml on the master node and set the below parameter.\n--anonymous-auth=false", + "severity": "low", + "tags": ["Kubernetes", "Containers"] + }, + "id": "sample_csp_rule_template", + "type": "csp-rule-template" +} diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/csp_rule_template/sample_csp_rule_template.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/csp_rule_template/sample_csp_rule_template.json new file mode 100644 index 0000000000000..97a24faebb3fd --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/csp_rule_template/sample_csp_rule_template.json @@ -0,0 +1,17 @@ +{ + "attributes": { + "benchmark": { "name": "CIS Kubernetes", "version": "1.4.1" }, + "benchmark_rule_id": "1.1.2", + "default_value": "By default, anonymous access is enabled.", + "description": "'Disable anonymous requests to the API server", + "impact": "Anonymous requests will be rejected.", + "name": "Ensure that the API server pod specification file permissions are set to 644 or more restrictive (Automated)", + "rationale": "When enabled, requests that are not rejected by other configured authentication methods\nare treated as anonymous requests. These requests are then served by the API server. You\nshould rely on authentication to authorize access and disallow anonymous requests.\nIf you are using RBAC authorization, it is generally considered reasonable to allow\nanonymous access to the API Server for health checks and discovery purposes, and hence\nthis recommendation is not scored. However, you should consider whether anonymous\ndiscovery is an acceptable risk for your purposes.", + "rego_rule_id": "cis_k8s.cis_1_1_1", + "remediation": "Edit the API server pod specification file /etc/kubernetes/manifests/kubeapiserver.yaml on the master node and set the below parameter.\n--anonymous-auth=false", + "severity": "low", + "tags": ["Kubernetes", "Containers"] + }, + "id": "sample_csp_rule_template2", + "type": "csp-rule-template" +} diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts index 5a5fb68a1dbc7..1f7377ba189ba 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts @@ -48,6 +48,7 @@ export default function (providerContext: FtrProviderContext) { name: 'Test policy', namespace: 'default', is_managed: false, + force: true, }); } } @@ -138,6 +139,7 @@ export default function (providerContext: FtrProviderContext) { name: agentPolicy.name, namespace: agentPolicy.namespace, is_managed: false, + force: true, }) .expect(200); }); diff --git a/x-pack/test/fleet_api_integration/config.ts b/x-pack/test/fleet_api_integration/config.ts index 38c0d2593070d..c58666259dc07 100644 --- a/x-pack/test/fleet_api_integration/config.ts +++ b/x-pack/test/fleet_api_integration/config.ts @@ -66,6 +66,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.fleet.packages.0.version=latest`, ...(registryPort ? [`--xpack.fleet.registryUrl=http://localhost:${registryPort}`] : []), `--xpack.fleet.developer.bundledPackageLocation=${BUNDLED_PACKAGE_DIR}`, + '--xpack.cloudSecurityPosture.enabled=true', // Enable debug fleet logs by default `--logging.loggers[0].name=plugins.fleet`, `--logging.loggers[0].level=debug`, diff --git a/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts b/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts index 3ab27e52477a6..9a2968a1fd8b5 100644 --- a/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts +++ b/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { asyncForEach } from '@kbn/std'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { @@ -14,6 +15,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const aceEditor = getService('aceEditor'); const retry = getService('retry'); const security = getService('security'); + const es = getService('es'); + const log = getService('log'); const editorTestSubjectSelector = 'searchProfilerEditor'; @@ -34,23 +37,23 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const okInput = [ `{ -"query": { -"match_all": {}`, + "query": { + "match_all": {}`, `{ -"query": { -"match_all": { -"test": """{ "more": "json" }"""`, + "query": { + "match_all": { + "test": """{ "more": "json" }"""`, ]; const notOkInput = [ `{ -"query": { -"match_all": { -"test": """{ "more": "json" }""`, + "query": { + "match_all": { + "test": """{ "more": "json" }""`, `{ -"query": { -"match_all": { -"test": """{ "more": "json" }""'`, + "query": { + "match_all": { + "test": """{ "more": "json" }""'`, ]; const expectHasParseErrorsToBe = (expectation: boolean) => async (inputs: string[]) => { @@ -70,5 +73,44 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await expectHasParseErrorsToBe(false)(okInput); await expectHasParseErrorsToBe(true)(notOkInput); }); + + describe('No indices', () => { + before(async () => { + // Delete any existing indices that were not properly cleaned up + try { + const indices = await es.indices.get({ + index: '*', + }); + const indexNames = Object.keys(indices); + + if (indexNames.length > 0) { + await asyncForEach(indexNames, async (indexName) => { + await es.indices.delete({ index: indexName }); + }); + } + } catch (e) { + log.debug('[Setup error] Error deleting existing indices'); + throw e; + } + }); + + it('returns error if profile is executed with no valid indices', async () => { + const input = { + query: { + match_all: {}, + }, + }; + + await aceEditor.setValue(editorTestSubjectSelector, JSON.stringify(input)); + + await testSubjects.click('profileButton'); + + await retry.waitFor('notification renders', async () => { + const notification = await testSubjects.find('noShardsNotification'); + const notificationText = await notification.getVisibleText(); + return notificationText.includes('Unable to profile'); + }); + }); + }); }); } diff --git a/x-pack/test/functional/apps/lens/show_underlying_data.ts b/x-pack/test/functional/apps/lens/show_underlying_data.ts index d6ae299baceaf..cbe6820ccef4d 100644 --- a/x-pack/test/functional/apps/lens/show_underlying_data.ts +++ b/x-pack/test/functional/apps/lens/show_underlying_data.ts @@ -82,7 +82,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('indexPattern-filter-by-input > switchQueryLanguageButton'); // apparently setting a filter requires some time before and after typing to work properly await PageObjects.common.sleep(1000); - await PageObjects.lens.setFilterBy('memory'); + await PageObjects.lens.setFilterBy('memory:*'); await PageObjects.common.sleep(1000); await PageObjects.lens.closeDimensionEditor(); @@ -97,7 +97,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('discoverChart'); // check the query expect(await queryBar.getQueryString()).be.eql( - '( ( ip: "220.120.146.16" ) OR ( ip: "152.56.56.106" ) OR ( ip: "111.55.80.52" ) )' + '( ( ip: "86.252.46.140" ) OR ( ip: "155.34.86.215" ) OR ( ip: "133.198.170.210" ) )' ); const filterPills = await filterBar.getFiltersLabel(); expect(filterPills.length).to.be(1); diff --git a/x-pack/test/functional/apps/transform/cloning.ts b/x-pack/test/functional/apps/transform/cloning.ts index 382f1b5ba75ab..3cbb0892bd4ec 100644 --- a/x-pack/test/functional/apps/transform/cloning.ts +++ b/x-pack/test/functional/apps/transform/cloning.ts @@ -333,8 +333,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('should display the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('should display the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 37647b48d3180..dc8190c877d61 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -589,8 +589,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('displays the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('displays the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts b/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts index 72467b3060ab1..2c7889572ce74 100644 --- a/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts +++ b/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts @@ -401,8 +401,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('displays the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('displays the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/transform/creation_saved_search.ts b/x-pack/test/functional/apps/transform/creation_saved_search.ts index acdc0c64ddda2..b33027da24341 100644 --- a/x-pack/test/functional/apps/transform/creation_saved_search.ts +++ b/x-pack/test/functional/apps/transform/creation_saved_search.ts @@ -232,8 +232,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('displays the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('displays the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts index a7368dfbedf07..a5b28a6bf6c06 100644 --- a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts +++ b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts @@ -56,11 +56,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('[SkipCloud] global dashboard read with global_upgrade_assistant_role', function () { this.tags('skipCloud'); - it('should render the "Stack" section with Upgrde Assistant', async function () { + it('should render the "Stack" section with Upgrade Assistant', async function () { await PageObjects.common.navigateToApp('management'); const sections = await managementMenu.getSections(); - expect(sections).to.have.length(3); - expect(sections[2]).to.eql({ + expect(sections).to.have.length(5); + expect(sections[4]).to.eql({ sectionId: 'stack', sectionLinks: ['license_management', 'upgrade_assistant'], }); diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 28000c3d4bac8..c32d6f7304aea 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -425,6 +425,14 @@ export default async function ({ readConfigFile }) { }, global_devtools_read: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['read', 'all'], + }, + ], + }, kibana: [ { feature: { @@ -456,9 +464,7 @@ export default async function ({ readConfigFile }) { }, kibana: [ { - feature: { - discover: ['read'], - }, + base: ['all'], spaces: ['*'], }, ], diff --git a/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json b/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json index f426ffae33e1c..80ccf200301c7 100644 --- a/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json +++ b/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json @@ -274,3 +274,145 @@ } } } + +{ + "type": "doc", + "value": { + "id": "978766", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "ti_abusech.malware", + "ingested": "2021-01-26T11:09:06.595350Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978783/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "domain should match the auditbeat hosts' data's source.ip", + "domain": "172.16.0.0", + "ip": "8.8.8.8", + "port": 777, + "first_seen": "2021-01-26T11:09:04.000Z", + "provider": "geenensp", + "type": "url", + "url": { + "full": "http://159.89.119.67:59600/bin.sh", + "scheme": "http" + } + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": false, + "tags": null, + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "978767", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "ti_abusech.malware", + "ingested": "2021-01-26T11:09:06.595350Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978783/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "domain should match the auditbeat hosts' data's source.ip", + "domain": "172.16.0.0", + "ip": "9.9.9.9", + "port": 123, + "first_seen": "2021-01-26T11:09:04.000Z", + "provider": "geenensp", + "type": "url", + "url": { + "full": "http://159.89.119.67:59600/bin.sh", + "scheme": "http" + } + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": false, + "tags": null, + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index 714ae52a6641b..2b95570a9fb1a 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -670,13 +670,13 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi await this.assertDestinationIndexValue(destinationIndex); }, - async assertCreateIndexPatternSwitchExists() { - await testSubjects.existOrFail(`transformCreateIndexPatternSwitch`, { allowHidden: true }); + async assertCreateDataViewSwitchExists() { + await testSubjects.existOrFail(`transformCreateDataViewSwitch`, { allowHidden: true }); }, - async assertCreateIndexPatternSwitchCheckState(expectedCheckState: boolean) { + async assertCreateDataViewSwitchCheckState(expectedCheckState: boolean) { const actualCheckState = - (await testSubjects.getAttribute('transformCreateIndexPatternSwitch', 'aria-checked')) === + (await testSubjects.getAttribute('transformCreateDataViewSwitch', 'aria-checked')) === 'true'; expect(actualCheckState).to.eql( expectedCheckState, diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts index 66d1e83700ded..40fd69246710b 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts @@ -16,7 +16,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const retry = getService('retry'); const browser = getService('browser'); - describe('cases list', () => { + // Failing: See https://github.com/elastic/kibana/issues/128468 + describe.skip('cases list', () => { before(async () => { await common.navigateToApp('cases'); await cases.api.deleteAllCases(); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts index 0f6e99ccf27f3..14f169d778ebe 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts @@ -30,7 +30,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('rulesTab'); } - describe('alerts list', function () { + describe('rules list', function () { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); await testSubjects.click('rulesTab'); @@ -390,6 +390,29 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); + it('should render interval info icon when schedule interval is less than configured minimum', async () => { + await createAlert({ + supertest, + objectRemover, + overwrites: { name: 'b', schedule: { interval: '1s' } }, + }); + await createAlert({ + supertest, + objectRemover, + overwrites: { name: 'c' }, + }); + await refreshAlertsList(); + + await testSubjects.existOrFail('ruleInterval-config-icon-0'); + await testSubjects.missingOrFail('ruleInterval-config-icon-1'); + + // open edit flyout when icon is clicked + const infoIcon = await testSubjects.find('ruleInterval-config-icon-0'); + await infoIcon.click(); + + await testSubjects.click('cancelSaveEditedRuleButton'); + }); + it('should delete all selection', async () => { const namePrefix = generateUniqueKey(); const createdAlert = await createAlertManualCleanup({ diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index b280e9a3e78c5..74595e812f42a 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -91,6 +91,28 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); } + async function createRuleWithSmallInterval( + testRunUuid: string, + params: Record = {} + ) { + const connectors = await createConnectors(testRunUuid); + return await createAlwaysFiringRule({ + name: `test-rule-${testRunUuid}`, + schedule: { + interval: '1s', + }, + actions: connectors.map((connector) => ({ + id: connector.id, + group: 'default', + params: { + message: 'from alert 1s', + level: 'warn', + }, + })), + params, + }); + } + async function getAlertSummary(ruleId: string) { const { body: summary } = await supertest .get(`/internal/alerting/rule/${encodeURIComponent(ruleId)}/_alert_summary`) @@ -116,7 +138,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const testRunUuid = uuid.v4(); before(async () => { await pageObjects.common.navigateToApp('triggersActions'); - const rule = await createRuleWithActionsAndParams(testRunUuid); + const rule = await createRuleWithSmallInterval(testRunUuid); // refresh to see rule await browser.refresh(); @@ -145,6 +167,15 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(connectorType).to.be(`Slack`); }); + it('renders toast when schedule is less than configured minimum', async () => { + await testSubjects.existOrFail('intervalConfigToast'); + + const editButton = await testSubjects.find('ruleIntervalToastEditButton'); + await editButton.click(); + + await testSubjects.click('cancelSaveEditedRuleButton'); + }); + it('should disable the rule', async () => { const enableSwitch = await testSubjects.find('enableSwitch'); diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index e906e239a8892..b2b6735a99c8b 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -67,7 +67,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--elasticsearch.hosts=https://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, `--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'alerts')}`, - `--xpack.alerting.rules.minimumScheduleInterval.value="1s"`, + `--xpack.alerting.rules.minimumScheduleInterval.value="2s"`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.preconfiguredAlertHistoryEsIndex=false`, `--xpack.actions.preconfigured=${JSON.stringify({ diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts index 745995588d8b3..2c203a4ffbcd3 100644 --- a/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts @@ -26,18 +26,28 @@ import { logsOnlySpacesAll, } from '../../../common/lib/authentication/users'; +type RuleRegistrySearchResponseWithErrors = RuleRegistrySearchResponse & { + statusCode: number; + message: string; +}; + // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); - // const bsearch = getService('bsearch'); const secureBsearch = getService('secureBsearch'); const log = getService('log'); + const kbnClient = getService('kibanaServer'); const SPACE1 = 'space1'; describe('ruleRegistryAlertsSearchStrategy', () => { + let kibanaVersion: string; + before(async () => { + kibanaVersion = await kbnClient.version.get(); + }); + describe('logs', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); @@ -53,10 +63,12 @@ export default ({ getService }: FtrProviderContext) => { username: logsOnlySpacesAll.username, password: logsOnlySpacesAll.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.LOGS], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.rawResponse.hits.total).to.eql(5); const consumers = result.rawResponse.hits.hits.map((hit) => { @@ -72,6 +84,8 @@ export default ({ getService }: FtrProviderContext) => { username: logsOnlySpacesAll.username, password: logsOnlySpacesAll.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.LOGS], pagination: { @@ -86,7 +100,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.rawResponse.hits.total).to.eql(5); expect(result.rawResponse.hits.hits.length).to.eql(2); @@ -94,9 +108,26 @@ export default ({ getService }: FtrProviderContext) => { const second = result.rawResponse.hits.hits[1].fields?.['kibana.alert.evaluation.value']; expect(first > second).to.be(true); }); + + it('should reject public requests', async () => { + const result = await secureBsearch.send({ + supertestWithoutAuth, + auth: { + username: logsOnlySpacesAll.username, + password: logsOnlySpacesAll.password, + }, + options: { + featureIds: [AlertConsumers.LOGS], + }, + strategy: 'privateRuleRegistryAlertsSearchStrategy', + }); + expect(result.statusCode).to.be(500); + expect(result.message).to.be( + `The privateRuleRegistryAlertsSearchStrategy search strategy is currently only available for internal use.` + ); + }); }); - // TODO: need xavier's help here describe('siem', () => { before(async () => { await createSignalsIndex(supertest, log); @@ -126,10 +157,12 @@ export default ({ getService }: FtrProviderContext) => { username: obsOnlySpacesAllEsRead.username, password: obsOnlySpacesAllEsRead.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.SIEM], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.rawResponse.hits.total).to.eql(1); const consumers = result.rawResponse.hits.hits.map( @@ -139,24 +172,22 @@ export default ({ getService }: FtrProviderContext) => { }); it('should throw an error when trying to to search for more than just siem', async () => { - type RuleRegistrySearchResponseWithErrors = RuleRegistrySearchResponse & { - statusCode: number; - message: string; - }; const result = await secureBsearch.send({ supertestWithoutAuth, auth: { username: obsOnlySpacesAllEsRead.username, password: obsOnlySpacesAllEsRead.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.SIEM, AlertConsumers.LOGS], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.statusCode).to.be(500); expect(result.message).to.be( - `The ruleRegistryAlertsSearchStrategy search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.` + `The privateRuleRegistryAlertsSearchStrategy search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.` ); }); }); @@ -176,10 +207,12 @@ export default ({ getService }: FtrProviderContext) => { username: obsOnlySpacesAll.username, password: obsOnlySpacesAll.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.APM], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', space: SPACE1, }); expect(result.rawResponse.hits.total).to.eql(2); @@ -198,10 +231,12 @@ export default ({ getService }: FtrProviderContext) => { username: obsOnlySpacesAll.username, password: obsOnlySpacesAll.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.rawResponse).to.eql({}); }); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts new file mode 100644 index 0000000000000..7e67c38347603 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts @@ -0,0 +1,343 @@ +/* + * 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 { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { PolicyTestResourceInfo } from '../../../security_solution_endpoint/services/endpoint_policy'; +import { ArtifactTestData } from '../../../security_solution_endpoint/services//endpoint_artifacts'; +import { + BY_POLICY_ARTIFACT_TAG_PREFIX, + GLOBAL_ARTIFACT_TAG, +} from '../../../../plugins/security_solution/common/endpoint/service/artifacts'; +import { ExceptionsListItemGenerator } from '../../../../plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator'; +import { + createUserAndRole, + deleteUserAndRole, + ROLES, +} from '../../../common/services/security_solution'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const endpointPolicyTestResources = getService('endpointPolicyTestResources'); + const endpointArtifactTestResources = getService('endpointArtifactTestResources'); + + describe('Endpoint artifacts (via lists plugin): Blocklists', () => { + let fleetEndpointPolicy: PolicyTestResourceInfo; + + before(async () => { + // Create an endpoint policy in fleet we can work with + fleetEndpointPolicy = await endpointPolicyTestResources.createPolicy(); + + // create role/user + await createUserAndRole(getService, ROLES.detections_admin); + }); + + after(async () => { + if (fleetEndpointPolicy) { + await fleetEndpointPolicy.cleanup(); + } + + // delete role/user + await deleteUserAndRole(getService, ROLES.detections_admin); + }); + + const anEndpointArtifactError = (res: { body: { message: string } }) => { + expect(res.body.message).to.match(/EndpointArtifactError/); + }; + const anErrorMessageWith = ( + value: string | RegExp + ): ((res: { body: { message: string } }) => void) => { + return (res) => { + if (value instanceof RegExp) { + expect(res.body.message).to.match(value); + } else { + expect(res.body.message).to.be(value); + } + }; + }; + + describe('and accessing blocklists', () => { + const exceptionsGenerator = new ExceptionsListItemGenerator(); + let blocklistData: ArtifactTestData; + + type BlocklistApiCallsInterface = Array<{ + method: keyof Pick; + info?: string; + path: string; + // The body just needs to have the properties we care about in the tests. This should cover most + // mocks used for testing that support different interfaces + getBody: () => BodyReturnType; + }>; + + beforeEach(async () => { + blocklistData = await endpointArtifactTestResources.createBlocklist({ + tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}${fleetEndpointPolicy.packagePolicy.id}`], + }); + }); + + afterEach(async () => { + if (blocklistData) { + await blocklistData.cleanup(); + } + }); + + const blocklistApiCalls: BlocklistApiCallsInterface< + Pick + > = [ + { + method: 'post', + path: EXCEPTION_LIST_ITEM_URL, + getBody: () => { + return exceptionsGenerator.generateBlocklistForCreate({ tags: [GLOBAL_ARTIFACT_TAG] }); + }, + }, + { + method: 'put', + path: EXCEPTION_LIST_ITEM_URL, + getBody: () => + exceptionsGenerator.generateBlocklistForUpdate({ + id: blocklistData.artifact.id, + item_id: blocklistData.artifact.item_id, + tags: [GLOBAL_ARTIFACT_TAG], + }), + }, + ]; + + describe('and has authorization to manage endpoint security', () => { + for (const blocklistApiCall of blocklistApiCalls) { + it(`should error on [${blocklistApiCall.method}] if invalid condition entry fields are used`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries[0].field = 'some.invalid.field'; + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/types that failed validation:/)); + }); + + it(`should error on [${blocklistApiCall.method}] if the same hash type is present twice`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries = [ + { + field: 'file.hash.sha256', + value: ['a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3'], + type: 'match_any', + operator: 'included', + }, + { + field: 'file.hash.sha256', + value: [ + '2C26B46B68FFC68FF99B453C1D30413413422D706483BFA0F98A5E886266E7AE', + 'FCDE2B2EDBA56BF408601FB721FE9B5C338D10EE429EA04FAE5511B68FBF8FB9', + ], + type: 'match_any', + operator: 'included', + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/duplicated/)); + }); + + it(`should error on [${blocklistApiCall.method}] if an invalid hash is used`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries = [ + { + field: 'file.hash.md5', + operator: 'included', + type: 'match_any', + value: ['1'], + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/invalid hash/)); + }); + + it(`should error on [${blocklistApiCall.method}] if no values`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries = [ + { + field: 'file.hash.md5', + operator: 'included', + type: 'match_any', + value: [], + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anErrorMessageWith(/Invalid value \"\[\]\"/)); + }); + + it(`should error on [${blocklistApiCall.method}] if signer is set for a non windows os entry item`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['linux']; + body.entries = [ + { + field: 'file.Ext.code_signature', + entries: [ + { + field: 'subject_name', + value: 'foo', + type: 'match', + operator: 'included', + }, + ], + type: 'nested', + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/^.*(?!file\.Ext\.code_signature)/)); + }); + + it(`should error on [${blocklistApiCall.method}] if more than one entry and not a hash`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['windows']; + body.entries = [ + { + field: 'file.path', + value: ['C:\\some\\path', 'C:\\some\\other\\path', 'C:\\yet\\another\\path'], + type: 'match_any', + operator: 'included', + }, + { + field: 'file.Ext.code_signature', + entries: [ + { + field: 'subject_name', + value: ['notsus.exe', 'verynotsus.exe', 'superlegit.exe'], + type: 'match_any', + operator: 'included', + }, + ], + type: 'nested', + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/one entry is allowed/)); + }); + + it(`should error on [${blocklistApiCall.method}] if more than one OS is set`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['linux', 'windows']; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/\[osTypes\]: array size is \[2\]/)); + }); + + it(`should error on [${blocklistApiCall.method}] if policy id is invalid`, async () => { + const body = blocklistApiCall.getBody(); + + body.tags = [`${BY_POLICY_ARTIFACT_TAG_PREFIX}123`]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/invalid policy ids/)); + }); + } + }); + + describe('and user DOES NOT have authorization to manage endpoint security', () => { + const allblocklistApiCalls: BlocklistApiCallsInterface = [ + ...blocklistApiCalls, + { + method: 'get', + info: 'single item', + get path() { + return `${EXCEPTION_LIST_ITEM_URL}?item_id=${blocklistData.artifact.item_id}&namespace_type=${blocklistData.artifact.namespace_type}`; + }, + getBody: () => undefined, + }, + { + method: 'get', + info: 'list summary', + get path() { + return `${EXCEPTION_LIST_URL}/summary?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}`; + }, + getBody: () => undefined, + }, + { + method: 'delete', + info: 'single item', + get path() { + return `${EXCEPTION_LIST_ITEM_URL}?item_id=${blocklistData.artifact.item_id}&namespace_type=${blocklistData.artifact.namespace_type}`; + }, + getBody: () => undefined, + }, + { + method: 'post', + info: 'list export', + get path() { + return `${EXCEPTION_LIST_URL}/_export?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}&id=1`; + }, + getBody: () => undefined, + }, + { + method: 'get', + info: 'single items', + get path() { + return `${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}&page=1&per_page=1&sort_field=name&sort_order=asc`; + }, + getBody: () => undefined, + }, + ]; + + for (const blocklistApiCall of allblocklistApiCalls) { + it(`should error on [${blocklistApiCall.method}]`, async () => { + await supertestWithoutAuth[blocklistApiCall.method](blocklistApiCall.path) + .auth(ROLES.detections_admin, 'changeme') + .set('kbn-xsrf', 'true') + .send(blocklistApiCall.getBody()) + .expect(403, { + status_code: 403, + message: 'EndpointArtifactError: Endpoint authorization failure', + }); + }); + } + }); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts index 5acb9d2e4261d..94a5a9122f187 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts @@ -35,5 +35,6 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider loadTestFile(require.resolve('./endpoint_artifacts/trusted_apps')); loadTestFile(require.resolve('./endpoint_artifacts/event_filters')); loadTestFile(require.resolve('./endpoint_artifacts/host_isolation_exceptions')); + loadTestFile(require.resolve('./endpoint_artifacts/blocklists')); }); } diff --git a/yarn.lock b/yarn.lock index 5163a6e68be50..cdcf07b3e7341 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12786,10 +12786,10 @@ ejs@^3.1.6: dependencies: jake "^10.6.1" -elastic-apm-http-client@11.0.0: - version "11.0.0" - resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-11.0.0.tgz#a38a85eae078e3f7f09edda86db6d6419a8ecfea" - integrity sha512-HB6+O0C4GGj9k5bd6yL3QK5prGKh+Rf8Tc5iW0T7FCdh2HliICfGmB6wmdQ2XkClblLtISh7tKYgVr9YgdXl3Q== +elastic-apm-http-client@11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-11.0.1.tgz#15dbe99d56d62b3f732d1bd2b51bef6094b78801" + integrity sha512-5AOWlhs2WlZpI+DfgGqY/8Rk7KF8WeevaO8R961eBylavU6GWhLRNiJncohn5jsvrqhmeT19azBvy/oYRN7bJw== dependencies: agentkeepalive "^4.2.1" breadth-filter "^2.0.0" @@ -12802,10 +12802,10 @@ elastic-apm-http-client@11.0.0: semver "^6.3.0" stream-chopper "^3.0.1" -elastic-apm-node@^3.30.0: - version "3.30.0" - resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.30.0.tgz#4df7110324535089f66f7a3a96bf37d2fe47f38b" - integrity sha512-KumRBDGIE+MGgJfteAi9BDqeGxpAYpbovWjNdB5x8T3/zpnQRJkDMSblliEsMwD6uKf2+Nkxzmyq9UZdh5MbGQ== +elastic-apm-node@^3.31.0: + version "3.31.0" + resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.31.0.tgz#6e0bf622d922c95ff0127a263babcdeaeea71457" + integrity sha512-0OulazfhkXYbOaGkHncqjwOfxtcvzsDyzUKr6Y1k95HwKrjf1Vi+xPutZv4p/WfDdO+JadphI0U2Uu5ncGB2iA== dependencies: "@elastic/ecs-pino-format" "^1.2.0" after-all-results "^2.0.0" @@ -12814,7 +12814,7 @@ elastic-apm-node@^3.30.0: basic-auth "^2.0.1" cookie "^0.4.0" core-util-is "^1.0.2" - elastic-apm-http-client "11.0.0" + elastic-apm-http-client "11.0.1" end-of-stream "^1.4.4" error-callsites "^2.0.4" error-stack-parser "^2.0.6"