From 2d75a37e42d80fd8ee0bfff68fb34c2dc3f7ff5b Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Wed, 21 Jun 2023 01:30:11 -0400 Subject: [PATCH 01/31] [Security Solution] Remove Tech Preview badge from insight form (#160069) ## Summary Small pr that changes nothing functionally, just removes a Tech Preview EuiBetaBadge as this feature will be GA in 8.9. image --- .../components/markdown_editor/plugins/insight/index.tsx | 4 ---- .../markdown_editor/plugins/insight/translations.ts | 7 ------- x-pack/plugins/translations/translations/fr-FR.json | 1 - x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 5 files changed, 14 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/index.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/index.tsx index 0284fb7a8a5ac..5277702f3f405 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/index.tsx @@ -15,7 +15,6 @@ import { EuiIcon, EuiSpacer, EuiCallOut, - EuiBetaBadge, EuiCodeBlock, EuiModalHeader, EuiModalHeaderTitle, @@ -432,9 +431,6 @@ const InsightEditorComponent = ({ /> )} - - - diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/translations.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/translations.ts index 72494dce3608c..4849a06c8eea9 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/translations.ts @@ -82,13 +82,6 @@ export const CANCEL_FORM_BUTTON = i18n.translate( } ); -export const TECH_PREVIEW = i18n.translate( - 'xpack.securitySolution.markdown.insight.technicalPreview', - { - defaultMessage: 'Technical Preview', - } -); - export const PARSE_ERROR = i18n.translate( 'xpack.securitySolution.markdownEditor.plugins.insightProviderError', { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 3c839d1c973fa..4be90d89d6878 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -32660,7 +32660,6 @@ "xpack.securitySolution.markdown.insight.modalCancelButtonLabel": "Annuler", "xpack.securitySolution.markdown.insight.relativeTimerange": "Plage temporelle relative", "xpack.securitySolution.markdown.insight.relativeTimerangeText": "Sélectionnez une plage horaire pour limiter la requête, par rapport à l'heure de création de l'alerte (facultatif).", - "xpack.securitySolution.markdown.insight.technicalPreview": "Version d'évaluation technique", "xpack.securitySolution.markdown.osquery.addModalConfirmButtonLabel": "Ajouter une recherche", "xpack.securitySolution.markdown.osquery.addModalTitle": "Ajouter une recherche", "xpack.securitySolution.markdown.osquery.editModalConfirmButtonLabel": "Enregistrer les modifications", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f51885cdb11d3..663b18d425472 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -32641,7 +32641,6 @@ "xpack.securitySolution.markdown.insight.modalCancelButtonLabel": "キャンセル", "xpack.securitySolution.markdown.insight.relativeTimerange": "相対的時間範囲", "xpack.securitySolution.markdown.insight.relativeTimerangeText": "アラートの作成日時に相対的な、クエリを限定するための時間範囲を選択します(任意)。", - "xpack.securitySolution.markdown.insight.technicalPreview": "テクニカルプレビュー", "xpack.securitySolution.markdown.osquery.addModalConfirmButtonLabel": "クエリを追加", "xpack.securitySolution.markdown.osquery.addModalTitle": "クエリを追加", "xpack.securitySolution.markdown.osquery.editModalConfirmButtonLabel": "変更を保存", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 02fdd4e443530..b61d162af69df 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -32637,7 +32637,6 @@ "xpack.securitySolution.markdown.insight.modalCancelButtonLabel": "取消", "xpack.securitySolution.markdown.insight.relativeTimerange": "相对时间范围", "xpack.securitySolution.markdown.insight.relativeTimerangeText": "选择相对于告警创建时间的时间范围(可选)以限制查询。", - "xpack.securitySolution.markdown.insight.technicalPreview": "技术预览", "xpack.securitySolution.markdown.osquery.addModalConfirmButtonLabel": "添加查询", "xpack.securitySolution.markdown.osquery.addModalTitle": "添加查询", "xpack.securitySolution.markdown.osquery.editModalConfirmButtonLabel": "保存更改", From d475b12a1f420941cb9e5cfb2f9c8f82b3c0ae56 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 21 Jun 2023 09:19:54 +0300 Subject: [PATCH 02/31] [Graph] Stabilize FTs (#160015) ## Summary Closes https://github.com/elastic/kibana/issues/159952 Stabilizes the graph tests. We have followed the same solution in other graph tests and seem to have fixed it. I also ran the flaky runner 100 times with 100% success https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2419 --- x-pack/test/functional/apps/graph/graph.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/test/functional/apps/graph/graph.ts b/x-pack/test/functional/apps/graph/graph.ts index f126384071b3d..b3ee528237002 100644 --- a/x-pack/test/functional/apps/graph/graph.ts +++ b/x-pack/test/functional/apps/graph/graph.ts @@ -26,6 +26,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.settings.createIndexPattern('secrepo', '@timestamp'); log.debug('navigateTo graph'); await PageObjects.common.navigateToApp('graph'); + await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.graph.createWorkspace(); }); @@ -85,6 +86,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { } it('should show correct node labels', async function () { + await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.graph.selectIndexPattern('secrepo'); await buildGraph(); const { nodes } = await PageObjects.graph.getGraphObjects(); From 599e2311a6540beecb003109ded5c5543e8571e6 Mon Sep 17 00:00:00 2001 From: Antonio Date: Wed, 21 Jun 2023 09:09:07 +0200 Subject: [PATCH 03/31] [Cases] Limit category filter in find cases API (#159989) ## Summary This PR limits the number of categories by which we can filter calls to the find cases API. The limit is now 100. --------- Co-authored-by: lcawl --- .../cases/case-apis-passthru.asciidoc | 4 +++- .../plugins/cases/common/constants/index.ts | 1 + .../plugins/cases/docs/openapi/bundled.json | 19 +++++++++++++++++++ .../plugins/cases/docs/openapi/bundled.yaml | 10 ++++++++++ .../paths/s@{spaceid}@api@cases@_find.yaml | 10 ++++++++++ .../cases/server/client/cases/find.test.ts | 11 +++++++++++ .../plugins/cases/server/client/cases/find.ts | 17 +++++++++++++++-- .../tests/common/cases/find_cases.ts | 8 +++++++- 8 files changed, 76 insertions(+), 4 deletions(-) diff --git a/docs/api-generated/cases/case-apis-passthru.asciidoc b/docs/api-generated/cases/case-apis-passthru.asciidoc index d983ab1d2a099..d0e7a67c50660 100644 --- a/docs/api-generated/cases/case-apis-passthru.asciidoc +++ b/docs/api-generated/cases/case-apis-passthru.asciidoc @@ -724,7 +724,9 @@ Any modifications made to this file will be overwritten.
assignees (optional)
-
Query Parameter — Filters the returned cases by assignees. Valid values are none or unique identifiers for the user profiles. These identifiers can be found by using the suggest user profile API. default: null
defaultSearchOperator (optional)
+
Query Parameter — Filters the returned cases by assignees. Valid values are none or unique identifiers for the user profiles. These identifiers can be found by using the suggest user profile API. default: null
category (optional)
+ +
Query Parameter — Filters the returned cases by category. Limited to 100 categories. default: null
defaultSearchOperator (optional)
Query Parameter — The default operator to use for the simple_query_string. default: OR
from (optional)
diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index 0407315328dfb..33473a9c80bfd 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -104,6 +104,7 @@ export const MAX_DOCS_PER_PAGE = 10000 as const; export const MAX_BULK_GET_ATTACHMENTS = MAX_DOCS_PER_PAGE; export const MAX_CONCURRENT_SEARCHES = 10 as const; export const MAX_BULK_GET_CASES = 1000 as const; +export const MAX_CATEGORY_FILTER_LENGTH = 100 as const; /** * Validation diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json index aee1e1aa751a5..92dcfe5779306 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.json +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -236,6 +236,25 @@ ] } }, + { + "name": "category", + "in": "query", + "description": "Filters the returned cases by category. Limited to 100 categories.", + "schema": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "example": "my-category" + }, { "name": "defaultSearchOperator", "in": "query", diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml index 8b93529b76b7e..3d53916c89df8 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.yaml +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -140,6 +140,16 @@ paths: - type: array items: type: string + - name: category + in: query + description: Filters the returned cases by category. Limited to 100 categories. + schema: + oneOf: + - type: string + - type: array + items: + type: string + example: my-category - name: defaultSearchOperator in: query description: The default operator to use for the simple_query_string. diff --git a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@_find.yaml b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@_find.yaml index 4ddd52db231f2..e04fbfe020c9f 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@_find.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@_find.yaml @@ -18,6 +18,16 @@ get: - type: array items: type: string + - name: category + in: query + description: Filters the returned cases by category. Limited to 100 categories. + schema: + oneOf: + - type: string + - type: array + items: + type: string + example: my-category - name: defaultSearchOperator in: query description: The default operator to use for the simple_query_string. diff --git a/x-pack/plugins/cases/server/client/cases/find.test.ts b/x-pack/plugins/cases/server/client/cases/find.test.ts index 1e5afd2c87b36..968f111b58516 100644 --- a/x-pack/plugins/cases/server/client/cases/find.test.ts +++ b/x-pack/plugins/cases/server/client/cases/find.test.ts @@ -8,6 +8,7 @@ import { v1 as uuidv1 } from 'uuid'; import type { Case } from '../../../common/api'; +import { MAX_CATEGORY_FILTER_LENGTH } from '../../../common/constants'; import { flattenCaseSavedObject } from '../../common/utils'; import { mockCases } from '../../mocks'; import { createCasesClientMockArgs, createCasesClientMockFindRequest } from '../mocks'; @@ -103,5 +104,15 @@ describe('find', () => { 'Error: Invalid value "foobar" supplied to "searchFields"' ); }); + + it(`throws an error when the category array has ${MAX_CATEGORY_FILTER_LENGTH} items`, async () => { + const category = Array(MAX_CATEGORY_FILTER_LENGTH + 1).fill('foobar'); + + const findRequest = createCasesClientMockFindRequest({ category }); + + await expect(find(findRequest, clientArgs)).rejects.toThrow( + `Error: Too many categories provided. The maximum allowed is ${MAX_CATEGORY_FILTER_LENGTH}` + ); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/cases/find.ts b/x-pack/plugins/cases/server/client/cases/find.ts index 8e9e68f79d66b..aa2bb36768207 100644 --- a/x-pack/plugins/cases/server/client/cases/find.ts +++ b/x-pack/plugins/cases/server/client/cases/find.ts @@ -8,6 +8,7 @@ import { isEmpty } from 'lodash'; import Boom from '@hapi/boom'; +import { MAX_CATEGORY_FILTER_LENGTH } from '../../../common/constants'; import type { CasesFindResponse, CasesFindRequest } from '../../../common/api'; import { CasesFindRequestRt, @@ -24,6 +25,16 @@ import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants'; import type { CasesFindQueryParams } from '../types'; import { decodeOrThrow } from '../../../common/api/runtime_types'; +/** + * Throws an error if the user tries to filter by more than MAX_CATEGORY_FILTER_LENGTH categories. + */ +function throwIfCategoryParamTooLong(category?: string[] | string) { + if (Array.isArray(category) && category.length > MAX_CATEGORY_FILTER_LENGTH) + throw Boom.badRequest( + `Too many categories provided. The maximum allowed is ${MAX_CATEGORY_FILTER_LENGTH}` + ); +} + /** * Retrieves a case and optionally its comments. * @@ -44,8 +55,7 @@ export const find = async ( try { const queryParams = decodeWithExcessOrThrow(CasesFindRequestRt)(params); - const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = - await authorization.getAuthorizationFilter(Operations.findCases); + throwIfCategoryParamTooLong(queryParams.category); /** * Assign users to a case is only available to Platinum+ @@ -63,6 +73,9 @@ export const find = async ( licensingService.notifyUsage(LICENSING_CASE_ASSIGNMENT_FEATURE); } + const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = + await authorization.getAuthorizationFilter(Operations.findCases); + const queryArgs: CasesFindQueryParams = { tags: queryParams.tags, reporters: queryParams.reporters, diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts index 60b1e789ec775..00bdaa5b25f0b 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts @@ -8,7 +8,7 @@ import { v1 as uuidv1 } from 'uuid'; import expect from '@kbn/expect'; -import { CASES_URL } from '@kbn/cases-plugin/common/constants'; +import { CASES_URL, MAX_CATEGORY_FILTER_LENGTH } from '@kbn/cases-plugin/common/constants'; import { Case, CaseSeverity, CaseStatuses, CommentType } from '@kbn/cases-plugin/common/api'; import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -349,6 +349,12 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + it('unhappy path - 400s when more than the maximum category fields are supplied', async () => { + const category = Array(MAX_CATEGORY_FILTER_LENGTH + 1).fill('foobar'); + + await findCases({ supertest, query: { category }, expectedHttpCode: 400 }); + }); + describe('search and searchField', () => { beforeEach(async () => { await createCase(supertest, postCaseReq); From 7ea54fa678580ffc46a90e512cbbbb4459949ac9 Mon Sep 17 00:00:00 2001 From: Marta Bondyra <4283304+mbondyra@users.noreply.github.com> Date: Wed, 21 Jun 2023 09:18:48 +0200 Subject: [PATCH 04/31] [Lens] fix inconsistent annotation icon styles (#159542) ## Summary Fixes https://github.com/elastic/kibana/issues/159540 Fixed by: 1. To fix the fill, I made the triangle and circle svgs consistent with the ones from EUI. Now they work with fill and not with stroke. Thanks to that we could remove `canFill` property. 2. To fix rotation for triangle, I added `!important` to transform when adding `xyAnnotationIcon_rotate90`. Btw this class was added to path and to svg, that's why before rotation was 45deg (because added to two elements). I fixed it too. Screenshot 2023-06-13 at 10 13 27 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/assets/annotation_icons/circle.tsx | 11 +---------- .../src/assets/annotation_icons/triangle.tsx | 9 +-------- packages/kbn-chart-icons/src/assets/common_styles.tsx | 4 ---- .../expression_xy/public/components/annotations.scss | 2 +- .../expression_xy/public/helpers/annotations.tsx | 8 +------- .../expression_xy/public/helpers/icon.ts | 2 -- .../components/annotation_editor_controls/icon_set.ts | 2 -- .../public/components/icon_select.tsx | 1 - x-pack/plugins/lens/public/app_plugin/app.scss | 4 ---- 9 files changed, 4 insertions(+), 39 deletions(-) diff --git a/packages/kbn-chart-icons/src/assets/annotation_icons/circle.tsx b/packages/kbn-chart-icons/src/assets/annotation_icons/circle.tsx index d3cb3789138f1..783e666bbc9e2 100644 --- a/packages/kbn-chart-icons/src/assets/annotation_icons/circle.tsx +++ b/packages/kbn-chart-icons/src/assets/annotation_icons/circle.tsx @@ -8,8 +8,6 @@ import React from 'react'; import { EuiIconProps } from '@elastic/eui'; -import { cx } from '@emotion/css'; -import { noFill } from '../common_styles'; export const IconCircle = ({ title, titleId, ...props }: Omit) => ( {title ? {title} : null} - + ); diff --git a/packages/kbn-chart-icons/src/assets/annotation_icons/triangle.tsx b/packages/kbn-chart-icons/src/assets/annotation_icons/triangle.tsx index 227dc84335821..fda92d5906fcd 100644 --- a/packages/kbn-chart-icons/src/assets/annotation_icons/triangle.tsx +++ b/packages/kbn-chart-icons/src/assets/annotation_icons/triangle.tsx @@ -8,8 +8,6 @@ import React from 'react'; import { EuiIconProps } from '@elastic/eui'; -import { cx } from '@emotion/css'; -import { noFill } from '../common_styles'; export const IconTriangle = ({ title, titleId, ...props }: Omit) => ( {title ? {title} : null} - + ); diff --git a/packages/kbn-chart-icons/src/assets/common_styles.tsx b/packages/kbn-chart-icons/src/assets/common_styles.tsx index 21409b5cb6670..79cb54e7f2839 100644 --- a/packages/kbn-chart-icons/src/assets/common_styles.tsx +++ b/packages/kbn-chart-icons/src/assets/common_styles.tsx @@ -17,7 +17,3 @@ export const colors = { fill: ${euiThemeVars.euiColorVis0}; `, }; - -export const noFill = css` - fill: none; -`; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/annotations.scss b/src/plugins/chart_expressions/expression_xy/public/components/annotations.scss index 06566258c3c39..644eec1749e97 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/annotations.scss +++ b/src/plugins/chart_expressions/expression_xy/public/components/annotations.scss @@ -13,7 +13,7 @@ } .xyAnnotationIcon_rotate90 { - transform: rotate(45deg); + transform: rotate(90deg) !important; transform-origin: center; } diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/annotations.tsx b/src/plugins/chart_expressions/expression_xy/public/helpers/annotations.tsx index 93cf570e86bc1..4e58cb315242f 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/annotations.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/annotations.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { Position } from '@elastic/charts'; import { EuiFlexGroup, EuiIcon, EuiIconProps, EuiText } from '@elastic/eui'; -import classnames from 'classnames'; import type { IconPosition, ReferenceLineDecorationConfig, @@ -180,12 +179,7 @@ export const AnnotationIcon = ({ {...rest} data-test-subj="xyVisAnnotationIcon" type={iconConfig.icon || type} - className={classnames( - { [rotateClassName]: iconConfig.shouldRotate }, - { - lensAnnotationIconFill: renderedInChart && iconConfig.canFill, - } - )} + className={iconConfig.shouldRotate ? rotateClassName : undefined} /> ); }; diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/icon.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/icon.ts index 652dbe168f58b..daf53ccc53fde 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/icon.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/icon.ts @@ -57,7 +57,6 @@ export const iconSet = [ defaultMessage: 'Circle', }), icon: IconCircle, - canFill: true, }, { @@ -113,6 +112,5 @@ export const iconSet = [ }), icon: IconTriangle, shouldRotate: true, - canFill: true, }, ]; diff --git a/src/plugins/event_annotation/public/components/annotation_editor_controls/icon_set.ts b/src/plugins/event_annotation/public/components/annotation_editor_controls/icon_set.ts index e4ea40acb48ed..36f8d4de9e7d4 100644 --- a/src/plugins/event_annotation/public/components/annotation_editor_controls/icon_set.ts +++ b/src/plugins/event_annotation/public/components/annotation_editor_controls/icon_set.ts @@ -48,7 +48,6 @@ export const annotationsIconSet: IconSet = [ defaultMessage: 'Circle', }), icon: IconCircle, - canFill: true, }, { @@ -106,6 +105,5 @@ export const annotationsIconSet: IconSet = [ }), icon: IconTriangle, shouldRotate: true, - canFill: true, }, ]; diff --git a/src/plugins/visualization_ui_components/public/components/icon_select.tsx b/src/plugins/visualization_ui_components/public/components/icon_select.tsx index 5b9d30c6cfb77..e515bf9c4d62c 100644 --- a/src/plugins/visualization_ui_components/public/components/icon_select.tsx +++ b/src/plugins/visualization_ui_components/public/components/icon_select.tsx @@ -26,7 +26,6 @@ export type IconSet = Array<{ label: string; icon?: T | IconType; shouldRotate?: boolean; - canFill?: boolean; }>; const IconView = (props: { value?: string; label: string; icon?: IconType }) => { diff --git a/x-pack/plugins/lens/public/app_plugin/app.scss b/x-pack/plugins/lens/public/app_plugin/app.scss index 2bc7d7e1baaaa..bd143f3245e93 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.scss +++ b/x-pack/plugins/lens/public/app_plugin/app.scss @@ -19,10 +19,6 @@ flex-grow: 1; } -.lensAnnotationIconFill { - fill: $euiColorEmptyShade; -} - // 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__withDivider { @include euiBreakpoint('m', 'l', 'xl') { From 0b91e6db9b5fadcf2146ae8c174d3a3b49383239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Wed, 21 Jun 2023 09:31:36 +0200 Subject: [PATCH 05/31] [Snapshot Telemetry] Use Serveless-specific channels (#159677) ## Summary Resolves #159234. It changes the Snapshot Telemetry channels to be different when running on Serverless. They have the suffix `{channel}-serverless` as agreed with the Platform Analytics team. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../get_telemetry_channel_endpoint.test.ts | 209 ++++++++++++++---- .../get_telemetry_channel_endpoint.ts | 18 +- src/plugins/telemetry/public/mocks.ts | 1 + src/plugins/telemetry/public/plugin.ts | 2 + .../public/services/telemetry_service.ts | 16 +- .../telemetry/server/config/config.test.ts | 64 ++++++ src/plugins/telemetry/server/config/config.ts | 8 + src/plugins/telemetry/server/fetcher.ts | 1 + src/plugins/telemetry/server/plugin.ts | 3 +- .../server/routes/telemetry_opt_in.ts | 4 +- .../routes/telemetry_opt_in_stats.test.ts | 2 + .../server/routes/telemetry_opt_in_stats.ts | 5 +- ...telemetry_management_section.test.tsx.snap | 1 + .../telemetry_management_section.test.tsx | 7 + .../test_suites/core_plugins/rendering.ts | 1 + 15 files changed, 284 insertions(+), 58 deletions(-) create mode 100644 src/plugins/telemetry/server/config/config.test.ts diff --git a/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.test.ts index 040019c13616c..8532db45672de 100644 --- a/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.test.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.test.ts @@ -32,66 +32,183 @@ describe('getBaseUrl', () => { }); describe('getChannel', () => { - it('throws on unknown channel', () => { - expect(() => - // @ts-expect-error - getChannel('ANY') - ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry channel ANY."`); - }); + describe('Classic offering', () => { + it('throws on unknown channel', () => { + expect(() => + // @ts-expect-error + getChannel('ANY', false) + ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry channel ANY."`); + }); - it('returns correct snapshot channel name', () => { - const channelName = getChannel('snapshot'); - expect(channelName).toMatchInlineSnapshot(`"kibana-snapshot"`); + it('returns correct snapshot channel name', () => { + const channelName = getChannel('snapshot', false); + expect(channelName).toMatchInlineSnapshot(`"kibana-snapshot"`); + }); + + it('returns correct optInStatus channel name', () => { + const channelName = getChannel('optInStatus', false); + expect(channelName).toMatchInlineSnapshot(`"kibana-opt-in-reports"`); + }); }); - it('returns correct optInStatus channel name', () => { - const channelName = getChannel('optInStatus'); - expect(channelName).toMatchInlineSnapshot(`"kibana-opt-in-reports"`); + describe('Serverless offering', () => { + it('throws on unknown channel', () => { + expect(() => + // @ts-expect-error + getChannel('ANY', true) + ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry channel ANY."`); + }); + + it('returns correct snapshot channel name', () => { + const channelName = getChannel('snapshot', true); + expect(channelName).toMatchInlineSnapshot(`"kibana-snapshot-serverless"`); + }); + + it('returns correct optInStatus channel name', () => { + const channelName = getChannel('optInStatus', true); + expect(channelName).toMatchInlineSnapshot(`"kibana-opt-in-reports-serverless"`); + }); }); }); describe('getTelemetryChannelEndpoint', () => { - it('throws on unknown env', () => { - expect(() => - // @ts-expect-error - getTelemetryChannelEndpoint({ env: 'ANY', channelName: 'snapshot' }) - ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry endpoint env ANY."`); - }); + describe('Classic offering', () => { + it('throws on unknown env', () => { + expect(() => + getTelemetryChannelEndpoint({ + // @ts-expect-error + env: 'ANY', + channelName: 'snapshot', + appendServerlessChannelsSuffix: false, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry endpoint env ANY."`); + }); - it('throws on unknown channelName', () => { - expect(() => - // @ts-expect-error - getTelemetryChannelEndpoint({ env: 'prod', channelName: 'ANY' }) - ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry channel ANY."`); - }); + it('throws on unknown channelName', () => { + expect(() => + getTelemetryChannelEndpoint({ + env: 'prod', + // @ts-expect-error + channelName: 'ANY', + appendServerlessChannelsSuffix: false, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry channel ANY."`); + }); - describe('snapshot channel', () => { - it('returns correct prod endpoint', () => { - const endpoint = getTelemetryChannelEndpoint({ env: 'prod', channelName: 'snapshot' }); - expect(endpoint).toMatchInlineSnapshot( - `"https://telemetry.elastic.co/v3/send/kibana-snapshot"` - ); + describe('snapshot channel', () => { + it('returns correct prod endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'prod', + channelName: 'snapshot', + appendServerlessChannelsSuffix: false, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry.elastic.co/v3/send/kibana-snapshot"` + ); + }); + it('returns correct staging endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'staging', + channelName: 'snapshot', + appendServerlessChannelsSuffix: false, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry-staging.elastic.co/v3/send/kibana-snapshot"` + ); + }); }); - it('returns correct staging endpoint', () => { - const endpoint = getTelemetryChannelEndpoint({ env: 'staging', channelName: 'snapshot' }); - expect(endpoint).toMatchInlineSnapshot( - `"https://telemetry-staging.elastic.co/v3/send/kibana-snapshot"` - ); + + describe('optInStatus channel', () => { + it('returns correct prod endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'prod', + channelName: 'optInStatus', + appendServerlessChannelsSuffix: false, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry.elastic.co/v3/send/kibana-opt-in-reports"` + ); + }); + it('returns correct staging endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'staging', + channelName: 'optInStatus', + appendServerlessChannelsSuffix: false, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry-staging.elastic.co/v3/send/kibana-opt-in-reports"` + ); + }); }); }); - describe('optInStatus channel', () => { - it('returns correct prod endpoint', () => { - const endpoint = getTelemetryChannelEndpoint({ env: 'prod', channelName: 'optInStatus' }); - expect(endpoint).toMatchInlineSnapshot( - `"https://telemetry.elastic.co/v3/send/kibana-opt-in-reports"` - ); + describe('Serverless offering', () => { + it('throws on unknown env', () => { + expect(() => + getTelemetryChannelEndpoint({ + // @ts-expect-error + env: 'ANY', + channelName: 'snapshot', + appendServerlessChannelsSuffix: true, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry endpoint env ANY."`); + }); + + it('throws on unknown channelName', () => { + expect(() => + getTelemetryChannelEndpoint({ + env: 'prod', + // @ts-expect-error + channelName: 'ANY', + appendServerlessChannelsSuffix: true, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry channel ANY."`); }); - it('returns correct staging endpoint', () => { - const endpoint = getTelemetryChannelEndpoint({ env: 'staging', channelName: 'optInStatus' }); - expect(endpoint).toMatchInlineSnapshot( - `"https://telemetry-staging.elastic.co/v3/send/kibana-opt-in-reports"` - ); + + describe('snapshot channel', () => { + it('returns correct prod endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'prod', + channelName: 'snapshot', + appendServerlessChannelsSuffix: true, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry.elastic.co/v3/send/kibana-snapshot-serverless"` + ); + }); + it('returns correct staging endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'staging', + channelName: 'snapshot', + appendServerlessChannelsSuffix: true, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry-staging.elastic.co/v3/send/kibana-snapshot-serverless"` + ); + }); + }); + + describe('optInStatus channel', () => { + it('returns correct prod endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'prod', + channelName: 'optInStatus', + appendServerlessChannelsSuffix: true, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry.elastic.co/v3/send/kibana-opt-in-reports-serverless"` + ); + }); + it('returns correct staging endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'staging', + channelName: 'optInStatus', + appendServerlessChannelsSuffix: true, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry-staging.elastic.co/v3/send/kibana-opt-in-reports-serverless"` + ); + }); }); }); }); diff --git a/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.ts index 75d83611b8c8d..ae86603356129 100644 --- a/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.ts @@ -18,17 +18,26 @@ export type TelemetryEnv = 'staging' | 'prod'; export interface GetTelemetryChannelEndpointConfig { channelName: ChannelName; env: TelemetryEnv; + appendServerlessChannelsSuffix: boolean; } -export function getChannel(channelName: ChannelName): string { +export function getChannel( + channelName: ChannelName, + appendServerlessChannelsSuffix: boolean +): string { + let channel: string; switch (channelName) { case 'snapshot': - return TELEMETRY_CHANNELS.SNAPSHOT_CHANNEL; + channel = TELEMETRY_CHANNELS.SNAPSHOT_CHANNEL; + break; case 'optInStatus': - return TELEMETRY_CHANNELS.OPT_IN_STATUS_CHANNEL; + channel = TELEMETRY_CHANNELS.OPT_IN_STATUS_CHANNEL; + break; default: throw new Error(`Unknown telemetry channel ${channelName}.`); } + + return appendServerlessChannelsSuffix ? `${channel}-serverless` : channel; } export function getBaseUrl(env: TelemetryEnv): string { @@ -45,9 +54,10 @@ export function getBaseUrl(env: TelemetryEnv): string { export function getTelemetryChannelEndpoint({ channelName, env, + appendServerlessChannelsSuffix, }: GetTelemetryChannelEndpointConfig): string { const baseUrl = getBaseUrl(env); - const channelPath = getChannel(channelName); + const channelPath = getChannel(channelName, appendServerlessChannelsSuffix); return `${baseUrl}${ENDPOINT_VERSION}/send/${channelPath}`; } diff --git a/src/plugins/telemetry/public/mocks.ts b/src/plugins/telemetry/public/mocks.ts index afe8f7037dca5..c4ecd11426b90 100644 --- a/src/plugins/telemetry/public/mocks.ts +++ b/src/plugins/telemetry/public/mocks.ts @@ -38,6 +38,7 @@ export function mockTelemetryService({ optIn: true, banner: true, allowChangingOptInStatus: true, + appendServerlessChannelsSuffix: false, telemetryNotifyUserAboutOptInDefault: true, userCanChangeSettings: true, labels: {}, diff --git a/src/plugins/telemetry/public/plugin.ts b/src/plugins/telemetry/public/plugin.ts index 9411212abebe9..4447be3f6640e 100644 --- a/src/plugins/telemetry/public/plugin.ts +++ b/src/plugins/telemetry/public/plugin.ts @@ -103,6 +103,8 @@ export interface TelemetryPluginConfig { hidePrivacyStatement?: boolean; /** Extra labels to add to the telemetry context */ labels: Record; + /** Whether to use Serverless-specific channels when reporting Snapshot Telemetry */ + appendServerlessChannelsSuffix: boolean; } function getTelemetryConstants(docLinks: DocLinksStart): TelemetryConstants { diff --git a/src/plugins/telemetry/public/services/telemetry_service.ts b/src/plugins/telemetry/public/services/telemetry_service.ts index 58e60d0db1f30..0629630fea48a 100644 --- a/src/plugins/telemetry/public/services/telemetry_service.ts +++ b/src/plugins/telemetry/public/services/telemetry_service.ts @@ -99,14 +99,22 @@ export class TelemetryService { /** Retrieve the opt-in/out notification URL **/ public getOptInStatusUrl = () => { - const { sendUsageTo } = this.config; - return getTelemetryChannelEndpoint({ channelName: 'optInStatus', env: sendUsageTo }); + const { appendServerlessChannelsSuffix, sendUsageTo } = this.config; + return getTelemetryChannelEndpoint({ + channelName: 'optInStatus', + env: sendUsageTo, + appendServerlessChannelsSuffix, + }); }; /** Retrieve the URL to report telemetry **/ public getTelemetryUrl = () => { - const { sendUsageTo } = this.config; - return getTelemetryChannelEndpoint({ channelName: 'snapshot', env: sendUsageTo }); + const { appendServerlessChannelsSuffix, sendUsageTo } = this.config; + return getTelemetryChannelEndpoint({ + channelName: 'snapshot', + env: sendUsageTo, + appendServerlessChannelsSuffix, + }); }; /** diff --git a/src/plugins/telemetry/server/config/config.test.ts b/src/plugins/telemetry/server/config/config.test.ts new file mode 100644 index 0000000000000..b5640695abe41 --- /dev/null +++ b/src/plugins/telemetry/server/config/config.test.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 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 { config } from './config'; + +describe('config', () => { + const baseContext = { + dist: true, + serverless: false, + }; + + describe('expect different defaults', () => { + test('on non-distributable', () => { + expect(config.schema.validate({}, { ...baseContext, dist: false })).toEqual( + expect.objectContaining({ + sendUsageTo: 'staging', + }) + ); + }); + + test('on distributable', () => { + expect(config.schema.validate({}, { ...baseContext, dist: true })).toEqual( + expect.objectContaining({ + sendUsageTo: 'prod', + }) + ); + }); + + test('on non-serverless', () => { + expect(config.schema.validate({}, { ...baseContext, serverless: false })).toEqual( + expect.objectContaining({ + appendServerlessChannelsSuffix: false, + }) + ); + }); + + test('on serverless', () => { + expect(config.schema.validate({}, { ...baseContext, serverless: true })).toEqual( + expect.objectContaining({ + appendServerlessChannelsSuffix: true, + }) + ); + }); + }); + + describe('appendServerlessChannelsSuffix', () => { + test.each([true, false])( + 'do not allow changing the default value (serverless: %p)', + (serverless) => { + expect(() => + config.schema.validate( + { appendServerlessChannelsSuffix: !serverless }, + { ...baseContext, serverless } + ) + ).toThrow(); + } + ); + }); +}); diff --git a/src/plugins/telemetry/server/config/config.ts b/src/plugins/telemetry/server/config/config.ts index 2a085c5bc239e..96c195864af7a 100644 --- a/src/plugins/telemetry/server/config/config.ts +++ b/src/plugins/telemetry/server/config/config.ts @@ -33,6 +33,13 @@ const configSchema = schema.object({ sendUsageFrom: schema.oneOf([schema.literal('server'), schema.literal('browser')], { defaultValue: 'server', }), + appendServerlessChannelsSuffix: schema.conditional( + schema.contextRef('serverless'), + true, + schema.literal(true), + schema.literal(false), + { defaultValue: schema.contextRef('serverless') } + ), // Used for extra enrichment of telemetry labels: labelsSchema, }); @@ -44,6 +51,7 @@ export const config: PluginConfigDescriptor = { exposeToBrowser: { banner: true, allowChangingOptInStatus: true, + appendServerlessChannelsSuffix: true, optIn: true, sendUsageFrom: true, sendUsageTo: true, diff --git a/src/plugins/telemetry/server/fetcher.ts b/src/plugins/telemetry/server/fetcher.ts index 66f75f8582ec3..a79e043551775 100644 --- a/src/plugins/telemetry/server/fetcher.ts +++ b/src/plugins/telemetry/server/fetcher.ts @@ -202,6 +202,7 @@ export class FetcherTask { const allowChangingOptInStatus = config.allowChangingOptInStatus; const configTelemetryOptIn = typeof config.optIn === 'undefined' ? null : config.optIn; const telemetryUrl = getTelemetryChannelEndpoint({ + appendServerlessChannelsSuffix: config.appendServerlessChannelsSuffix, channelName: 'snapshot', env: config.sendUsageTo, }); diff --git a/src/plugins/telemetry/server/plugin.ts b/src/plugins/telemetry/server/plugin.ts index 49ec1fdf27756..ddbf7704d3838 100644 --- a/src/plugins/telemetry/server/plugin.ts +++ b/src/plugins/telemetry/server/plugin.ts @@ -205,8 +205,9 @@ export class TelemetryPlugin implements Plugin { - const { sendUsageTo } = await firstValueFrom(config$); + const { appendServerlessChannelsSuffix, sendUsageTo } = await firstValueFrom(config$); const telemetryUrl = getTelemetryChannelEndpoint({ + appendServerlessChannelsSuffix, env: sendUsageTo, channelName: 'snapshot', }); diff --git a/src/plugins/telemetry/server/routes/telemetry_opt_in.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts index cc477c4f23198..f523031e181ed 100644 --- a/src/plugins/telemetry/server/routes/telemetry_opt_in.ts +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts @@ -90,10 +90,10 @@ export function registerTelemetryOptInRoutes({ ); if (config.sendUsageFrom === 'server') { - const { sendUsageTo } = config; + const { appendServerlessChannelsSuffix, sendUsageTo } = config; sendTelemetryOptInStatus( telemetryCollectionManager, - { sendUsageTo, newOptInStatus, currentKibanaVersion }, + { appendServerlessChannelsSuffix, sendUsageTo, newOptInStatus, currentKibanaVersion }, statsGetterConfig ).catch((err) => { // The server is likely behind a firewall and can't reach the remote service diff --git a/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.test.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.test.ts index 3989e4c750567..bc26f0d2f46a7 100644 --- a/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.test.ts +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.test.ts @@ -26,6 +26,7 @@ describe('sendTelemetryOptInStatus', () => { it('calls fetch with the opt in status returned from the telemetryCollectionManager', async () => { const mockConfig = { + appendServerlessChannelsSuffix: false, sendUsageTo: 'prod' as const, newOptInStatus: true, currentKibanaVersion: 'mock_kibana_version', @@ -57,6 +58,7 @@ describe('sendTelemetryOptInStatus', () => { it('sends to staging endpoint on "sendUsageTo: staging"', async () => { const mockConfig = { + appendServerlessChannelsSuffix: false, sendUsageTo: 'staging' as const, newOptInStatus: true, currentKibanaVersion: 'mock_kibana_version', diff --git a/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts index 8c9c85172ced6..f715c84fc9341 100644 --- a/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts @@ -20,6 +20,7 @@ import { getTelemetryChannelEndpoint } from '../../common/telemetry_config'; import { PAYLOAD_CONTENT_ENCODING } from '../../common/constants'; interface SendTelemetryOptInStatusConfig { + appendServerlessChannelsSuffix: boolean; sendUsageTo: 'staging' | 'prod'; newOptInStatus: boolean; currentKibanaVersion: string; @@ -30,8 +31,10 @@ export async function sendTelemetryOptInStatus( config: SendTelemetryOptInStatusConfig, statsGetterConfig: StatsGetterConfig ): Promise { - const { sendUsageTo, newOptInStatus, currentKibanaVersion } = config; + const { appendServerlessChannelsSuffix, sendUsageTo, newOptInStatus, currentKibanaVersion } = + config; const optInStatusUrl = getTelemetryChannelEndpoint({ + appendServerlessChannelsSuffix, env: sendUsageTo, channelName: 'optInStatus', }); diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index d713986321f2d..57fa0b68affc8 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -261,6 +261,7 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "currentKibanaVersion": "mock_kibana_version", "defaultConfig": Object { "allowChangingOptInStatus": false, + "appendServerlessChannelsSuffix": false, "banner": true, "labels": Object {}, "optIn": true, diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx index 7576c0f35b6a9..a635f5668f52b 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx @@ -25,6 +25,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, sendUsageTo: 'staging', banner: true, allowChangingOptInStatus: true, @@ -57,6 +58,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, banner: true, allowChangingOptInStatus: true, optIn: false, @@ -109,6 +111,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, banner: true, allowChangingOptInStatus: true, optIn: false, @@ -155,6 +158,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, banner: true, allowChangingOptInStatus: false, optIn: true, @@ -192,6 +196,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, banner: true, allowChangingOptInStatus: true, optIn: false, @@ -233,6 +238,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, banner: true, allowChangingOptInStatus: true, optIn: false, @@ -281,6 +287,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, banner: true, allowChangingOptInStatus: false, optIn: false, diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index d2f053d378c18..1e8dc4eb899d5 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -141,6 +141,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'newsfeed.service.pathTemplate (string)', 'newsfeed.service.urlRoot (string)', 'telemetry.allowChangingOptInStatus (boolean)', + 'telemetry.appendServerlessChannelsSuffix (any)', // It's a boolean (any because schema.conditional) 'telemetry.banner (boolean)', 'telemetry.labels.branch (string)', 'telemetry.labels.ciBuildId (string)', From 5bac117af3d753e184de6b8ac9e6f21e56f0f21f Mon Sep 17 00:00:00 2001 From: Juan Pablo Djeredjian Date: Wed, 21 Jun 2023 09:44:53 +0200 Subject: [PATCH 06/31] [Security Solution] Fix rule severity sorting in inMemory tables (#160075) ## Summary - Fix rule severity sorting in in-memory tables (Add Rules table and Rule Upgrade table) so that rows are filtered by the semantic meaning of severity. ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../use_add_prebuilt_rules_table_columns.tsx | 3 ++- .../components/rules_table/helpers.ts | 12 ++++++++++++ .../use_upgrade_prebuilt_rules_table_columns.tsx | 4 +++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/use_add_prebuilt_rules_table_columns.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/use_add_prebuilt_rules_table_columns.tsx index 234350129986c..d21fd175ed60c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/use_add_prebuilt_rules_table_columns.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/use_add_prebuilt_rules_table_columns.tsx @@ -21,6 +21,7 @@ import { hasUserCRUDPermission } from '../../../../../common/utils/privileges'; import type { AddPrebuiltRulesTableActions } from './add_prebuilt_rules_table_context'; import { useAddPrebuiltRulesTableContext } from './add_prebuilt_rules_table_context'; import type { RuleSignatureId } from '../../../../../../common/detection_engine/rule_schema'; +import { getNormalizedSeverity } from '../helpers'; export type TableColumn = EuiBasicTableColumn; @@ -130,7 +131,7 @@ export const useAddPrebuiltRulesTableColumns = (): TableColumn[] => { field: 'severity', name: i18n.COLUMN_SEVERITY, render: (value: Rule['severity']) => , - sortable: true, + sortable: ({ severity }: RuleInstallationInfoForReview) => getNormalizedSeverity(severity), truncateText: true, width: '12%', }, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/helpers.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/helpers.ts index 49d29fc34da76..fda192dca2a67 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/helpers.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/helpers.ts @@ -6,6 +6,7 @@ */ import type { Query } from '@elastic/eui'; +import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; import type { ExportRulesDetails } from '../../../../../common/detection_engine/rule_management'; import type { BulkActionSummary } from '../../../rule_management/logic'; @@ -75,3 +76,14 @@ export const getExportedRulesCounts = async (blob: Blob): Promise = { + low: 0, + medium: 1, + high: 2, + critical: 3, +}; + +export const getNormalizedSeverity = (severity: Severity): number => { + return NormalizedSeverity[severity] ?? -1; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_prebuilt_rules_table_columns.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_prebuilt_rules_table_columns.tsx index 78ef1f7069ace..603d06c821fa6 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_prebuilt_rules_table_columns.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_prebuilt_rules_table_columns.tsx @@ -19,6 +19,7 @@ import { SeverityBadge } from '../../../../../detections/components/rules/severi import { useUserData } from '../../../../../detections/components/user_info'; import * as i18n from '../../../../../detections/pages/detection_engine/rules/translations'; import type { Rule } from '../../../../rule_management/logic'; +import { getNormalizedSeverity } from '../helpers'; import type { UpgradePrebuiltRulesTableActions } from './upgrade_prebuilt_rules_table_context'; import { useUpgradePrebuiltRulesTableContext } from './upgrade_prebuilt_rules_table_context'; @@ -130,7 +131,8 @@ export const useUpgradePrebuiltRulesTableColumns = (): TableColumn[] => { field: 'rule.severity', name: i18n.COLUMN_SEVERITY, render: (value: Rule['severity']) => , - sortable: true, + sortable: ({ rule: { severity } }: RuleUpgradeInfoForReview) => + getNormalizedSeverity(severity), truncateText: true, width: '12%', }, From 2ec067f7474118d089f46c0e2a9e726dc4c6c852 Mon Sep 17 00:00:00 2001 From: Jeramy Soucy Date: Wed, 21 Jun 2023 10:03:31 +0200 Subject: [PATCH 07/31] Fixes per-object audit logging for saved object resolve actions (#160014) ## Summary It was noticed that audit logs for Saved Object 'resolve' actions were not getting generated. On investigation we found that this regression was introduced in #148165. During work on migrating saved object authorization and auditing logic into the saved object security extension, several abstracted functions were created for reuse. One of these is responsible for performing audit logging (`auditHelper`). In migration of the unit tests, inputs to the `auditHelper`, via `authorize` and `enforceAuthoriztion`, were not correctly validated for `authorizeAndRedactInternalBulkResolve`. This PR corrects the regression by passing the saved object information through the call chain to `auditHelper`, and addresses the error in the unit tests by implementing checks to validate the input parameters at each level of the call chain. This PR also corrects the unit test for auditObjectsForSpaceDeletion. ### Tests See `x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.test.ts` '#authorizeAndRedactInternalBulkResolve' ### Manual testing: - Enable audit logging, example: ```yaml xpack.security.audit.enabled: true xpack.security.audit.appender: type: rolling-file fileName: ./logs/audit.log policy: type: time-interval interval: 24h strategy: type: numeric max: 10 layout: type: json ``` - Create several dashboards (or add all sample data sets) - Open each dashboard in the Kibana UI - Check the audit logs and verify that there is a "saved_object_resolve" entry to describe each dashboard access event, and that each log contains the dashboard type and id, example: > {"event":{"action":"saved_object_resolve","category":["database"],"type":["access"],"outcome":"success"},"kibana":{"space_id":"default","session_id":"HrDKOgZ6EiEV2bf8Io9bc/enfGsS+fTtbQ5g2ap21CU=","saved_object":{"type":"dashboard","id":"7adfa750-4c81-11e8-b3d7-01146121b73d"}},"user":{"id":"u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0","name":"elastic","roles":["superuser"]},"trace":{"id":"b11f0414-ca68-4e28-854a-249b19f88c36"},"client":{"ip":"127.0.0.1"},"http":{"request":{"headers":{"x-forwarded-for":"127.0.0.1"}}},"service":{"node":{"roles":["background_tasks","ui"]}},"ecs":{"version":"8.6.1"},"@timestamp":"2023-06-20T18:30:20.943+02:00","message":"User has resolved dashboard [id=7adfa750-4c81-11e8-b3d7-01146121b73d]","log":{"level":"INFO","logger":"plugins.security.audit.ecs"},"process":{"pid":15004},"transaction":{"id":"4917a352c3f68cd6"}} ## Release Note: This PR fixes a regression where the "saved_object_resolve" audit action was not being logged per object. --- .../saved_objects_security_extension.test.ts | 174 +++++++++++------- .../saved_objects_security_extension.ts | 2 +- 2 files changed, 104 insertions(+), 72 deletions(-) diff --git a/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.test.ts b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.test.ts index f1379f9af0ef6..278cba8393741 100644 --- a/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.test.ts +++ b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.test.ts @@ -4751,6 +4751,13 @@ describe('#authorizeAndRedactInternalBulkResolve', () => { ['login:']: { authorizedSpaces: ['x', 'foo'] }, }); + const auditObjects = objects.map((obj) => { + return { + type: obj.saved_object.type, + id: obj.saved_object.id, + }; + }); + test('returns empty array when no objects are provided`', async () => { const { securityExtension } = setup(); const emptyObjects: Array | BulkResolveError> = []; @@ -4790,6 +4797,7 @@ describe('#authorizeAndRedactInternalBulkResolve', () => { spaces: expectedSpaces, enforceMap: expectedEnforceMap, auditOptions: { + objects: auditObjects, useSuccessOutcome: true, }, }); @@ -4817,7 +4825,7 @@ describe('#authorizeAndRedactInternalBulkResolve', () => { action: SecurityAction.INTERNAL_BULK_RESOLVE, typesAndSpaces: expectedEnforceMap, typeMap: partiallyAuthorizedTypeMap, - auditOptions: { useSuccessOutcome: true }, + auditOptions: { objects: auditObjects, useSuccessOutcome: true }, }); }); @@ -4835,7 +4843,7 @@ describe('#authorizeAndRedactInternalBulkResolve', () => { action: SecurityAction.INTERNAL_BULK_RESOLVE, typesAndSpaces: expectedEnforceMap, typeMap: fullyAuthorizedTypeMap, - auditOptions: { useSuccessOutcome: true }, + auditOptions: { objects: auditObjects, useSuccessOutcome: true }, }); expect(result).toEqual(objects); }); @@ -4866,37 +4874,44 @@ describe('#authorizeAndRedactInternalBulkResolve', () => { action: 'saved_object_resolve', addToSpaces: undefined, deleteFromSpaces: undefined, - objects: undefined, + objects: auditObjects, useSuccessOutcome: true, }); - expect(addAuditEventSpy).toHaveBeenCalledTimes(1); - expect(addAuditEventSpy).toHaveBeenCalledWith({ - action: 'saved_object_resolve', - addToSpaces: undefined, - deleteFromSpaces: undefined, - unauthorizedSpaces: undefined, - unauthorizedTypes: undefined, - error: undefined, - outcome: 'success', - }); - expect(auditLogger.log).toHaveBeenCalledTimes(1); - expect(auditLogger.log).toHaveBeenCalledWith({ - error: undefined, - event: { - action: AuditAction.RESOLVE, - category: ['database'], + expect(addAuditEventSpy).toHaveBeenCalledTimes(auditObjects.length); + let i = 1; + for (const auditObj of auditObjects) { + expect(addAuditEventSpy).toHaveBeenNthCalledWith(i++, { + action: 'saved_object_resolve', + addToSpaces: undefined, + deleteFromSpaces: undefined, + unauthorizedSpaces: undefined, + unauthorizedTypes: undefined, + error: undefined, outcome: 'success', - type: ['access'], - }, - kibana: { - add_to_spaces: undefined, - delete_from_spaces: undefined, - unauthorized_spaces: undefined, - unauthorized_types: undefined, - saved_object: undefined, - }, - message: `User has resolved saved objects`, - }); + savedObject: auditObj, + }); + } + expect(auditLogger.log).toHaveBeenCalledTimes(auditObjects.length); + i = 1; + for (const auditObj of auditObjects) { + expect(auditLogger.log).toHaveBeenNthCalledWith(i++, { + error: undefined, + event: { + action: AuditAction.RESOLVE, + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + add_to_spaces: undefined, + delete_from_spaces: undefined, + unauthorized_spaces: undefined, + unauthorized_types: undefined, + saved_object: auditObj, + }, + message: `User has resolved ${auditObj.type} [id=${auditObj.id}]`, + }); + } }); test(`throws when unauthorized`, async () => { @@ -4939,28 +4954,31 @@ describe('#authorizeAndRedactInternalBulkResolve', () => { ); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); - expect(addAuditEventSpy).toHaveBeenCalledTimes(1); - expect(auditLogger.log).toHaveBeenCalledTimes(1); - expect(auditLogger.log).toHaveBeenCalledWith({ - error: { - code: 'Error', - message: `Unable to bulk_get ${resolveObj1.saved_object.type},${resolveObj2.saved_object.type}`, - }, - event: { - action: AuditAction.RESOLVE, - category: ['database'], - outcome: 'failure', - type: ['access'], - }, - kibana: { - add_to_spaces: undefined, - delete_from_spaces: undefined, - unauthorized_spaces: undefined, - unauthorized_types: undefined, - saved_object: undefined, - }, - message: `Failed attempt to resolve saved objects`, - }); + expect(addAuditEventSpy).toHaveBeenCalledTimes(objects.length); + expect(auditLogger.log).toHaveBeenCalledTimes(objects.length); + let i = 1; + for (const auditObj of auditObjects) { + expect(auditLogger.log).toHaveBeenNthCalledWith(i++, { + error: { + code: 'Error', + message: `Unable to bulk_get ${resolveObj1.saved_object.type},${resolveObj2.saved_object.type}`, + }, + event: { + action: AuditAction.RESOLVE, + category: ['database'], + outcome: 'failure', + type: ['access'], + }, + kibana: { + add_to_spaces: undefined, + delete_from_spaces: undefined, + unauthorized_spaces: undefined, + unauthorized_types: undefined, + saved_object: auditObj, + }, + message: `Failed attempt to resolve ${auditObj.type} [id=${auditObj.id}]`, + }); + } }); }); @@ -6196,25 +6214,39 @@ describe(`#auditObjectsForSpaceDeletion`, () => { expect(auditHelperSpy).not.toHaveBeenCalled(); // The helper is not called, the addAudit method is called directly expect(addAuditEventSpy).toHaveBeenCalledTimes(objects.length - 1); expect(auditLogger.log).toHaveBeenCalledTimes(objects.length - 1); - const i = 0; - for (const obj of objects) { - if (i === 0) continue; // The first object namespaces includes '*', so there will not be an audit for it - expect(auditLogger.log).toHaveBeenNthCalledWith(i, { - error: undefined, - event: { - action: AuditAction.UPDATE_OBJECTS_SPACES, - category: ['database'], - outcome: 'unknown', - type: ['change'], - }, - kibana: { - add_to_spaces: undefined, - delete_from_spaces: obj.namespaces!.length > 1 ? obj.namespaces : undefined, - saved_object: undefined, - }, - message: `User is updating spaces of dashboard [id=${obj.id}]`, - }); - } + // The first object's namespaces includes '*', so there will not be an audit for it + + // The second object only exists in the space we're deleting, so it is audited as a delete + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + error: undefined, + event: { + action: AuditAction.DELETE, + category: ['database'], + outcome: 'unknown', + type: ['deletion'], + }, + kibana: { + delete_from_spaces: undefined, + saved_object: { type: objects[1].type, id: objects[1].id }, + }, + message: `User is deleting dashboard [id=${objects[1].id}]`, + }); + + // The third object exists in spaces other than what we're deleting, so it is audited as a change + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + error: undefined, + event: { + action: AuditAction.UPDATE_OBJECTS_SPACES, + category: ['database'], + outcome: 'unknown', + type: ['change'], + }, + kibana: { + delete_from_spaces: [spaceId], + saved_object: { type: objects[2].type, id: objects[2].id }, + }, + message: `User is updating spaces of dashboard [id=${objects[2].id}]`, + }); }); }); diff --git a/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts index fe62b40285ca4..e53b129474a62 100644 --- a/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts +++ b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts @@ -1212,7 +1212,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten types: new Set(typesAndSpaces.keys()), spaces: spacesToAuthorize, enforceMap: typesAndSpaces, - auditOptions: { useSuccessOutcome: true }, + auditOptions: { objects: auditableObjects, useSuccessOutcome: true }, }); return objects.map((result) => { From 6f87995de103956ee101e3b4265bd7c1fc26728b Mon Sep 17 00:00:00 2001 From: Artem Shelkovnikov Date: Wed, 21 Jun 2023 11:18:51 +0200 Subject: [PATCH 08/31] Change service_type for Sharepoint Server connector (#160028) ## Summary With changes from Crest team to move Sharepoint connector to be a Sharepoint Server connector, we need to update `service_type` for the service created by Kibana, see https://github.com/elastic/connectors-python/pull/1065. This PR does it. --- .../plugins/enterprise_search/common/connectors/connectors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/enterprise_search/common/connectors/connectors.ts b/x-pack/plugins/enterprise_search/common/connectors/connectors.ts index ba366cec67a03..67c0398458bf6 100644 --- a/x-pack/plugins/enterprise_search/common/connectors/connectors.ts +++ b/x-pack/plugins/enterprise_search/common/connectors/connectors.ts @@ -137,7 +137,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.sharepoint.name', { defaultMessage: 'Sharepoint Server', }), - serviceType: 'sharepoint', + serviceType: 'sharepoint_server', }, { iconPath: 'sharepoint_online.svg', From b6adcce4e8135de752d061568559266016732c10 Mon Sep 17 00:00:00 2001 From: Maryam Saeidi Date: Wed, 21 Jun 2023 12:15:21 +0200 Subject: [PATCH 09/31] [AO] Fix developer console warnings related to rule details page (#160034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #159285 ## Summary This PR fixes the following warnings. |First warning|Second Warning| |---|---| |![image](https://github.com/elastic/kibana/assets/12370520/bf8e3e4b-0885-4a28-9485-3c5ff73efe42)|![image](https://github.com/elastic/kibana/assets/12370520/91375b77-f739-4273-8d3f-1fe4a71c794e)| ## 🧪 How to test Make sure the following components look like before. ![image](https://github.com/elastic/kibana/assets/12370520/34ea58e8-e8ce-4e58-9b2c-04245f462016) --- .../hideable_react_query_dev_tools.tsx | 1 + .../event_log/event_log_list_status.tsx | 4 +- .../components/rule_event_log_list_kpi.tsx | 9 +-- .../rule_execution_summary_and_chart.test.tsx | 26 +++---- .../rule_execution_summary_and_chart.tsx | 72 +++++++++++-------- 5 files changed, 60 insertions(+), 52 deletions(-) diff --git a/x-pack/plugins/observability/public/application/hideable_react_query_dev_tools.tsx b/x-pack/plugins/observability/public/application/hideable_react_query_dev_tools.tsx index b09bbb8656699..322d8a467ef38 100644 --- a/x-pack/plugins/observability/public/application/hideable_react_query_dev_tools.tsx +++ b/x-pack/plugins/observability/public/application/hideable_react_query_dev_tools.tsx @@ -20,6 +20,7 @@ export function HideableReactQueryDevTools() { color="primary" style={{ zIndex: 99999, position: 'fixed', bottom: '40px', left: '40px' }} onClick={() => setIsHidden(!isHidden)} + aria-label="Hide react query" />
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/event_log/event_log_list_status.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/event_log/event_log_list_status.tsx index 44b72072f836d..2dc460fc755ca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/event_log/event_log_list_status.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/event_log/event_log_list_status.tsx @@ -56,9 +56,9 @@ export const EventLogListStatus = (props: EventLogListStatusProps) => { }, [useExecutionStatus, status]); return ( -
+ {statusString} -
+ ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.tsx index 2f0aa0460c754..9e393a6507fc6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useState, useMemo, useRef } from 'react'; import { i18n } from '@kbn/i18n'; import datemath from '@kbn/datemath'; -import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui'; import { IExecutionKPIResult } from '@kbn/alerting-plugin/common'; import { ComponentOpts as RuleApis, @@ -130,12 +130,7 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => { const isLoadingData = useMemo(() => isLoading || !kpi, [isLoading, kpi]); const getStatDescription = (element: React.ReactNode) => { - return ( - <> - {element} - - - ); + return {element}; }; return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.test.tsx index c9626bad07dd6..89f330515091e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.test.tsx @@ -85,13 +85,12 @@ describe('rule_execution_summary_and_chart', () => { const avgExecutionDurationPanel = wrapper.find('[data-test-subj="avgExecutionDurationPanel"]'); expect(avgExecutionDurationPanel.exists()).toBeTruthy(); expect(avgExecutionDurationPanel.first().prop('color')).toEqual('subdued'); - expect(wrapper.find('EuiStat[data-test-subj="avgExecutionDurationStat"]').text()).toEqual( + expect(wrapper.find('EuiPanel[data-test-subj="avgExecutionDurationPanel"]').text()).toEqual( 'Average duration00:00:00.100' ); expect(wrapper.find('[data-test-subj="ruleDurationWarning"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="executionDurationChartPanel"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="avgExecutionDurationPanel"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="ruleEventLogListAvgDuration"]').first().text()).toEqual( '00:00:00.100' ); @@ -115,15 +114,17 @@ describe('rule_execution_summary_and_chart', () => { // Does not fetch for the rule summary by itself expect(loadRuleSummaryMock).toHaveBeenCalledTimes(1); - ( - wrapper - .find('[data-test-subj="executionDurationChartPanelSelect"]') - .first() - .prop('onChange') as any - )({ - target: { - value: 30, - }, + await act(async () => { + ( + wrapper + .find('[data-test-subj="executionDurationChartPanelSelect"]') + .first() + .prop('onChange') as any + )({ + target: { + value: 30, + }, + }); }); await act(async () => { @@ -137,13 +138,12 @@ describe('rule_execution_summary_and_chart', () => { const avgExecutionDurationPanel = wrapper.find('[data-test-subj="avgExecutionDurationPanel"]'); expect(avgExecutionDurationPanel.exists()).toBeTruthy(); expect(avgExecutionDurationPanel.first().prop('color')).toEqual('subdued'); - expect(wrapper.find('EuiStat[data-test-subj="avgExecutionDurationStat"]').text()).toEqual( + expect(wrapper.find('EuiPanel[data-test-subj="avgExecutionDurationPanel"]').text()).toEqual( 'Average duration00:00:00.100' ); expect(wrapper.find('[data-test-subj="ruleDurationWarning"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="executionDurationChartPanel"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="avgExecutionDurationPanel"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="ruleEventLogListAvgDuration"]').first().text()).toEqual( '00:00:00.100' ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.tsx index f3e7409b66d2b..6a577e9ecf667 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.tsx @@ -7,7 +7,14 @@ import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiPanel, EuiStat, EuiFlexItem, EuiFlexGroup, EuiIconTip } from '@elastic/eui'; +import { + EuiPanel, + EuiFlexItem, + EuiFlexGroup, + EuiIconTip, + EuiText, + useEuiTheme, +} from '@elastic/eui'; import { RuleSummary, RuleType } from '../../../../types'; import { useKibana } from '../../../../common/lib/kibana'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; @@ -36,6 +43,13 @@ type RuleExecutionSummaryAndChartProps = { fetchRuleSummary?: boolean; } & Pick; +const ruleTypeExcessDurationMessage = i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.alertsList.ruleTypeExcessDurationMessage', + { + defaultMessage: `Duration exceeds the rule's expected run time.`, + } +); + export const RuleExecutionSummaryAndChart = (props: RuleExecutionSummaryAndChartProps) => { const { ruleId, @@ -52,6 +66,7 @@ export const RuleExecutionSummaryAndChart = (props: RuleExecutionSummaryAndChart const { notifications: { toasts }, } = useKibana().services; + const { euiTheme } = useEuiTheme(); const isInitialized = useRef(false); @@ -154,40 +169,37 @@ export const RuleExecutionSummaryAndChart = (props: RuleExecutionSummaryAndChart color={showDurationWarning ? 'warning' : 'subdued'} hasBorder={false} > - - {showDurationWarning && ( - - - - )} - - {formatMillisForDisplay(computedRuleSummary.executionDuration.average)} - - - } - description={i18n.translate( + + {i18n.translate( 'xpack.triggersActionsUI.sections.ruleDetails.alertsList.avgDurationDescription', { defaultMessage: `Average duration`, } )} - /> + + + {showDurationWarning && ( + + + + )} + + + + {formatMillisForDisplay(computedRuleSummary.executionDuration.average)} + + + + From b4624a9993e94f55fee09bb0486520a61f8df45a Mon Sep 17 00:00:00 2001 From: Marta Bondyra <4283304+mbondyra@users.noreply.github.com> Date: Wed, 21 Jun 2023 12:19:48 +0200 Subject: [PATCH 10/31] [Lens] Quick Function Incompatability Indicator Getting Cut Off (#160046) ## Summary Fixes https://github.com/elastic/kibana/issues/152520 Before Screenshot 2023-06-20 at 17 39 47 After Screenshot 2023-06-20 at 17 40 51 Also fixed broken theme and missing I18nProvider from the extra action that has a separate mounting point. Screenshot 2023-06-21 at 09 54 41 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../dimension_panel/dimension_editor.tsx | 13 +- .../datasources/form_based/help_popover.tsx | 39 ++-- .../text_based/text_based_languages.tsx | 158 +++++++++-------- .../xy/annotations/actions/save_action.tsx | 166 +++++++++--------- 4 files changed, 201 insertions(+), 175 deletions(-) diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx index 9d7ff324f62d9..32f40368601aa 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx @@ -429,8 +429,16 @@ export function DimensionEditor(props: DimensionEditorProps) { } else if (!compatibleWithCurrentField) { label = ( - - {label} + + + {label} + {shouldDisplayDots && ( @@ -502,6 +510,7 @@ export function DimensionEditor(props: DimensionEditorProps) { helpPopoverContainer.current = null; } }} + theme={props.core.theme} > diff --git a/x-pack/plugins/lens/public/datasources/form_based/help_popover.tsx b/x-pack/plugins/lens/public/datasources/form_based/help_popover.tsx index 803899b3028fd..0959bc7b42287 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/help_popover.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/help_popover.tsx @@ -18,6 +18,9 @@ import { EuiText, } from '@elastic/eui'; import './help_popover.scss'; +import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { ThemeServiceStart } from '@kbn/core/public'; export const HelpPopoverButton = ({ children, @@ -79,6 +82,7 @@ export const WrappingHelpPopover = ({ closePopover, isOpen, title, + theme, }: { anchorPosition?: EuiWrappingPopoverProps['anchorPosition']; button: EuiWrappingPopoverProps['button']; @@ -86,23 +90,28 @@ export const WrappingHelpPopover = ({ closePopover: EuiWrappingPopoverProps['closePopover']; isOpen: EuiWrappingPopoverProps['isOpen']; title?: string; + theme: ThemeServiceStart; }) => { return ( - - {title && {title}} + + + + {title && {title}} - - {children} - - + + {children} + + + + ); }; diff --git a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx index ae0a4d9cf71f9..fc504a33aa743 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx @@ -379,17 +379,21 @@ export function getTextBasedDatasource({ } render( - , + + + {' '} + + , domElement ); }, @@ -421,73 +425,75 @@ export function getTextBasedDatasource({ }); render( - - { - const meta = fields.find((f) => f.name === choice.field)?.meta; - const newColumn = { - columnId: props.columnId, - fieldName: choice.field, - meta, - }; - return props.setState( - !selectedField - ? { - ...props.state, - layers: { - ...props.state.layers, - [props.layerId]: { - ...props.state.layers[props.layerId], - columns: [...props.state.layers[props.layerId].columns, newColumn], - allColumns: [ - ...props.state.layers[props.layerId].allColumns, - newColumn, - ], + + + { + const meta = fields.find((f) => f.name === choice.field)?.meta; + const newColumn = { + columnId: props.columnId, + fieldName: choice.field, + meta, + }; + return props.setState( + !selectedField + ? { + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: [...props.state.layers[props.layerId].columns, newColumn], + allColumns: [ + ...props.state.layers[props.layerId].allColumns, + newColumn, + ], + }, }, - }, - } - : { - ...props.state, - layers: { - ...props.state.layers, - [props.layerId]: { - ...props.state.layers[props.layerId], - columns: props.state.layers[props.layerId].columns.map((col) => - col.columnId !== props.columnId - ? col - : { ...col, fieldName: choice.field, meta } - ), - allColumns: props.state.layers[props.layerId].allColumns.map((col) => - col.columnId !== props.columnId - ? col - : { ...col, fieldName: choice.field, meta } - ), + } + : { + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: props.state.layers[props.layerId].columns.map((col) => + col.columnId !== props.columnId + ? col + : { ...col, fieldName: choice.field, meta } + ), + allColumns: props.state.layers[props.layerId].allColumns.map((col) => + col.columnId !== props.columnId + ? col + : { ...col, fieldName: choice.field, meta } + ), + }, }, - }, - } - ); - }} - /> - - {props.dataSectionExtra && ( -
- {props.dataSectionExtra} -
- )} + } + ); + }} + /> +
+ {props.dataSectionExtra && ( +
+ {props.dataSectionExtra} +
+ )} +
, domElement ); diff --git a/x-pack/plugins/lens/public/visualizations/xy/annotations/actions/save_action.tsx b/x-pack/plugins/lens/public/visualizations/xy/annotations/actions/save_action.tsx index d2de460da39f8..d0888189b8592 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/annotations/actions/save_action.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/annotations/actions/save_action.tsx @@ -51,48 +51,46 @@ export const SaveModal = ({ const closeModal = () => unmountComponentAtNode(domElement); return ( - - onSave({ ...props, closeModal, newTags: selectedTags })} - onClose={closeModal} - title={title} - description={description} - showCopyOnSave={showCopyOnSave} - objectType={i18n.translate( - 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.objectType', - { defaultMessage: 'group' } - )} - customModalTitle={i18n.translate( - 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.modalTitle', - { - defaultMessage: 'Save annotation group to library', - } - )} - showDescription={true} - confirmButtonLabel={ - <> -
- -
-
- {i18n.translate( - 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.confirmButton', - { defaultMessage: 'Save group' } - )} -
- - } - options={ - savedObjectsTagging ? ( - - ) : undefined + onSave({ ...props, closeModal, newTags: selectedTags })} + onClose={closeModal} + title={title} + description={description} + showCopyOnSave={showCopyOnSave} + objectType={i18n.translate( + 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.objectType', + { defaultMessage: 'group' } + )} + customModalTitle={i18n.translate( + 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.modalTitle', + { + defaultMessage: 'Save annotation group to library', } - /> -
+ )} + showDescription={true} + confirmButtonLabel={ + <> +
+ +
+
+ {i18n.translate( + 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.confirmButton', + { defaultMessage: 'Save group' } + )} +
+ + } + options={ + savedObjectsTagging ? ( + + ) : undefined + } + /> ); }; @@ -206,27 +204,29 @@ export const onSave = async ({ ), text: ((element) => render( -

- goToAnnotationLibrary()} - > - {i18n.translate( - 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.annotationLibrary', - { - defaultMessage: 'annotation library', - } - )} - - ), - }} - /> -

, + +

+ goToAnnotationLibrary()} + > + {i18n.translate( + 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.annotationLibrary', + { + defaultMessage: 'annotation library', + } + )} + + ), + }} + /> +

+
, element )) as MountPoint, }); @@ -272,26 +272,28 @@ export const getSaveLayerAction = ({ if (domElement) { render( - { - await onSave({ - state, - layer, - setState, - eventAnnotationService, - toasts, - modalOnSaveProps: props, - dataViews, - goToAnnotationLibrary, - }); - }} - title={neverSaved ? '' : layer.__lastSaved.title} - description={neverSaved ? '' : layer.__lastSaved.description} - tags={neverSaved ? [] : layer.__lastSaved.tags} - showCopyOnSave={!neverSaved} - /> + + { + await onSave({ + state, + layer, + setState, + eventAnnotationService, + toasts, + modalOnSaveProps: props, + dataViews, + goToAnnotationLibrary, + }); + }} + title={neverSaved ? '' : layer.__lastSaved.title} + description={neverSaved ? '' : layer.__lastSaved.description} + tags={neverSaved ? [] : layer.__lastSaved.tags} + showCopyOnSave={!neverSaved} + /> + , domElement ); From 834298cc7b84068829a72c8009640ab10d89a023 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 21 Jun 2023 14:08:35 +0300 Subject: [PATCH 11/31] [Cases] Category fixes (#160026) ## Summary This PR: - Fixes a bug with the category sorting - Fixes a bug where the save button is not disabled on error or no changes - Fixes a bug where case-sensitive categories cannot be added - Fixes a bug where if the category change from another user the form does not reflect the change - Fixes a UX bug where it is difficult to understand how to remove a category - Add more tests ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- x-pack/plugins/cases/common/api/cases/case.ts | 1 - .../cases/common/utils/validators.test.ts | 4 + .../public/common/mock/test_providers.tsx | 25 ----- .../common/{test_utils.ts => test_utils.tsx} | 25 +++++ .../cases/public/common/translations.ts | 2 +- .../all_cases/all_cases_list.test.tsx | 103 +++++++++-------- .../all_cases/table_filters.test.tsx | 8 +- .../components/assign_users.test.tsx | 18 ++- .../components/edit_category.test.tsx | 98 ++++++++++++++++ .../case_view/components/edit_category.tsx | 78 +++++++++++-- .../category/category_component.test.tsx | 16 +++ .../category/category_component.tsx | 1 + .../category/category_form_field.test.tsx | 62 ++++------- .../category_viewer_component.test.tsx | 3 - .../components/category/translations.ts | 11 ++ .../removable_item/removable_item.test.tsx | 105 ++++++++++++++++++ .../removable_item/removable_item.tsx | 74 ++++++++++++ .../components/user_actions/category.test.tsx | 4 +- .../components/user_actions/category.tsx | 2 +- .../user_profiles/removable_user.test.tsx | 8 +- .../user_profiles/removable_user.tsx | 52 ++------- .../cases/public/containers/api.test.tsx | 20 ++++ .../public/containers/use_get_categories.tsx | 7 +- x-pack/plugins/cases/server/client/utils.ts | 12 ++ .../apps/cases/group1/create_case_form.ts | 4 +- .../apps/cases/group1/view_case.ts | 20 ++-- 26 files changed, 556 insertions(+), 207 deletions(-) rename x-pack/plugins/cases/public/common/{test_utils.ts => test_utils.tsx} (68%) create mode 100644 x-pack/plugins/cases/public/components/removable_item/removable_item.test.tsx create mode 100644 x-pack/plugins/cases/public/components/removable_item/removable_item.tsx diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index 8d9bd960c42e6..8f1e7a1264166 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -258,7 +258,6 @@ export const CasesFindRequestRt = rt.exact( /** * The field to use for sorting the found objects. * - * This only supports, `create_at`, `closed_at`, and `status` */ sortField: rt.string, /** diff --git a/x-pack/plugins/cases/common/utils/validators.test.ts b/x-pack/plugins/cases/common/utils/validators.test.ts index f196d0120affe..78675a18422ff 100644 --- a/x-pack/plugins/cases/common/utils/validators.test.ts +++ b/x-pack/plugins/cases/common/utils/validators.test.ts @@ -72,6 +72,10 @@ describe('validators', () => { it('returns true if the category is an empty string', () => { expect(isCategoryFieldInvalidString('')).toBe(true); }); + + it('returns true if the string contains only spaces', () => { + expect(isCategoryFieldInvalidString(' ')).toBe(true); + }); }); describe('isCategoryFieldTooLong', () => { diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index 985857d349e58..7dfed5d7188de 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -13,7 +13,6 @@ import { ThemeProvider } from 'styled-components'; import type { RenderOptions, RenderResult } from '@testing-library/react'; import type { ILicense } from '@kbn/licensing-plugin/public'; -import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { ScopedFilesClient } from '@kbn/files-plugin/public'; import { euiDarkVars } from '@kbn/ui-theme'; @@ -220,27 +219,3 @@ export const createAppMockRenderer = ({ getFilesClient, }; }; - -export const useFormFieldMock = (options?: Partial>): FieldHook => ({ - path: 'path', - type: 'type', - value: 'mockedValue' as unknown as T, - isPristine: false, - isDirty: false, - isModified: false, - isValidating: false, - isValidated: false, - isChangingValue: false, - errors: [], - isValid: true, - getErrorsMessages: jest.fn(), - onChange: jest.fn(), - setValue: jest.fn(), - setErrors: jest.fn(), - clearErrors: jest.fn(), - validate: jest.fn(), - reset: jest.fn(), - __isIncludedInOutput: true, - __serializeValue: jest.fn(), - ...options, -}); diff --git a/x-pack/plugins/cases/public/common/test_utils.ts b/x-pack/plugins/cases/public/common/test_utils.tsx similarity index 68% rename from x-pack/plugins/cases/public/common/test_utils.ts rename to x-pack/plugins/cases/public/common/test_utils.tsx index db6dfaaf11c31..66e1bd6bd8656 100644 --- a/x-pack/plugins/cases/public/common/test_utils.ts +++ b/x-pack/plugins/cases/public/common/test_utils.tsx @@ -5,9 +5,14 @@ * 2.0. */ +/* eslint-disable react/display-name */ + +import React from 'react'; import type { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import type { MatcherFunction } from '@testing-library/react'; +import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { EuiButton } from '@elastic/eui'; /** * Convenience utility to remove text appended to links by EUI @@ -39,3 +44,23 @@ export const createQueryWithMarkup = ); return hasText(node) && childrenDontHaveText; }); + +interface FormTestComponentProps { + formDefaultValue?: Record; + onSubmit?: jest.Mock; +} + +export const FormTestComponent: React.FC = ({ + children, + onSubmit, + formDefaultValue, +}) => { + const { form } = useForm({ onSubmit, defaultValue: formDefaultValue }); + + return ( +
+ {children} + form.submit()}>{'Submit'} +
+ ); +}; diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 0aa5b9de5cb3e..f427789332610 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -235,7 +235,7 @@ export const ADD_CATEGORY = i18n.translate('xpack.cases.caseView.addCategory', { defaultMessage: 'added the category', }); -export const REMOVE_CATEGORY = i18n.translate('xpack.cases.caseView.removeCategory', { +export const REMOVE_CATEGORY = i18n.translate('xpack.cases.caseView.userAction.removeCategory', { defaultMessage: 'removed the category', }); diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 4fc84b7cf68e0..0df062cad52b7 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -38,7 +38,11 @@ import { useGetSupportedActionConnectors } from '../../containers/configure/use_ import { useGetTags } from '../../containers/use_get_tags'; import { useGetCategories } from '../../containers/use_get_categories'; import { useUpdateCase } from '../../containers/use_update_case'; -import { useGetCases, DEFAULT_QUERY_PARAMS } from '../../containers/use_get_cases'; +import { + useGetCases, + DEFAULT_QUERY_PARAMS, + DEFAULT_FILTER_OPTIONS, +} from '../../containers/use_get_cases'; import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock'; import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; @@ -82,7 +86,6 @@ const mockKibana = () => { }; describe('AllCasesListGeneric', () => { - const refetchCases = jest.fn(); const onRowClick = jest.fn(); const updateCaseProperty = jest.fn(); @@ -90,7 +93,6 @@ describe('AllCasesListGeneric', () => { const defaultGetCases = { ...useGetCasesMockState, - refetch: refetchCases, }; const defaultColumnArgs = { @@ -151,8 +153,8 @@ describe('AllCasesListGeneric', () => { jest.clearAllMocks(); appMockRenderer = createAppMockRenderer(); useGetCasesMock.mockReturnValue(defaultGetCases); - useGetTagsMock.mockReturnValue({ data: ['coke', 'pepsi'], refetch: jest.fn() }); - useGetCategoriesMock.mockReturnValue({ data: ['twix', 'snickers'], refetch: jest.fn() }); + useGetTagsMock.mockReturnValue({ data: ['coke', 'pepsi'], isLoading: false }); + useGetCategoriesMock.mockReturnValue({ data: ['twix', 'snickers'], isLoading: false }); useGetCurrentUserProfileMock.mockReturnValue({ data: userProfiles[0], isLoading: false }); useBulkGetUserProfilesMock.mockReturnValue({ data: userProfilesMap }); useGetConnectorsMock.mockImplementation(() => ({ data: connectorsMock, isLoading: false })); @@ -294,34 +296,22 @@ describe('AllCasesListGeneric', () => { }); }); - it('renders the title column', async () => { - appMockRenderer.render(); - - expect(screen.getByTestId('tableHeaderCell_title_0')).toBeInTheDocument(); - }); - - it('renders the category column', async () => { - appMockRenderer.render(); - - expect(screen.getByTestId('tableHeaderCell_category_4')).toBeInTheDocument(); - }); - - it('renders the updated on column', async () => { - appMockRenderer.render(); - - expect(screen.getByTestId('tableHeaderCell_updatedAt_6')).toBeInTheDocument(); - }); - - it('renders the status column', async () => { - appMockRenderer.render(); - - expect(screen.getByTestId('tableHeaderCell_status_8')).toBeInTheDocument(); - }); - - it('renders the severity column', async () => { - appMockRenderer.render(); + it('renders the columns correctly', async () => { + appMockRenderer.render(); - expect(screen.getByTestId('tableHeaderCell_severity_9')).toBeInTheDocument(); + const casesTable = within(screen.getByTestId('cases-table')); + + expect(casesTable.getByTitle('Name')).toBeInTheDocument(); + expect(casesTable.getByTitle('Category')).toBeInTheDocument(); + expect(casesTable.getByTitle('Created on')).toBeInTheDocument(); + expect(casesTable.getByTitle('Updated on')).toBeInTheDocument(); + expect(casesTable.getByTitle('Status')).toBeInTheDocument(); + expect(casesTable.getByTitle('Severity')).toBeInTheDocument(); + expect(casesTable.getByTitle('Tags')).toBeInTheDocument(); + expect(casesTable.getByTitle('Alerts')).toBeInTheDocument(); + expect(casesTable.getByTitle('Comments')).toBeInTheDocument(); + expect(casesTable.getByTitle('External incident')).toBeInTheDocument(); + expect(casesTable.getByTitle('Actions')).toBeInTheDocument(); }); it('should not render table utility bar when isSelectorView=true', async () => { @@ -403,9 +393,7 @@ describe('AllCasesListGeneric', () => { it('should sort by status', async () => { appMockRenderer.render(); - userEvent.click( - within(screen.getByTestId('tableHeaderCell_status_8')).getByTestId('tableHeaderSortButton') - ); + userEvent.click(screen.getByTitle('Status')); await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( @@ -423,20 +411,17 @@ describe('AllCasesListGeneric', () => { it('should render Name, Category, CreatedOn and Severity columns when isSelectorView=true', async () => { appMockRenderer.render(); await waitFor(() => { - expect(screen.getByTestId('tableHeaderCell_title_0')).toBeInTheDocument(); - expect(screen.getByTestId('tableHeaderCell_category_1')).toBeInTheDocument(); - expect(screen.getByTestId('tableHeaderCell_createdAt_2')).toBeInTheDocument(); - expect(screen.getByTestId('tableHeaderCell_severity_3')).toBeInTheDocument(); - expect(screen.queryByTestId('tableHeaderCell_assignees_1')).not.toBeInTheDocument(); + expect(screen.getByTitle('Name')).toBeInTheDocument(); + expect(screen.getByTitle('Category')).toBeInTheDocument(); + expect(screen.getByTitle('Created on')).toBeInTheDocument(); + expect(screen.getByTitle('Severity')).toBeInTheDocument(); }); }); it('should sort by severity', async () => { appMockRenderer.render(); - userEvent.click( - within(screen.getByTestId('tableHeaderCell_severity_9')).getByTestId('tableHeaderSortButton') - ); + userEvent.click(screen.getByTitle('Severity')); await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( @@ -454,9 +439,7 @@ describe('AllCasesListGeneric', () => { it('should sort by title', async () => { appMockRenderer.render(); - userEvent.click( - within(screen.getByTestId('tableHeaderCell_title_0')).getByTestId('tableHeaderSortButton') - ); + userEvent.click(screen.getByTitle('Name')); await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( @@ -474,9 +457,7 @@ describe('AllCasesListGeneric', () => { it('should sort by updatedOn', async () => { appMockRenderer.render(); - userEvent.click( - within(screen.getByTestId('tableHeaderCell_updatedAt_6')).getByTestId('tableHeaderSortButton') - ); + userEvent.click(screen.getByTitle('Updated on')); await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( @@ -494,9 +475,7 @@ describe('AllCasesListGeneric', () => { it('should sort by category', async () => { appMockRenderer.render(); - userEvent.click( - within(screen.getByTestId('tableHeaderCell_category_4')).getByTestId('tableHeaderSortButton') - ); + userEvent.click(screen.getByTitle('Category')); await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( @@ -511,6 +490,26 @@ describe('AllCasesListGeneric', () => { }); }); + it('should filter by category', async () => { + appMockRenderer.render(); + + userEvent.click(screen.getByTestId('options-filter-popover-button-Categories')); + await waitForEuiPopoverOpen(); + userEvent.click(screen.getByTestId('options-filter-popover-item-twix')); + + await waitFor(() => { + expect(useGetCasesMock).toHaveBeenLastCalledWith({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + searchFields: [], + owner: ['securitySolution'], + category: ['twix'], + }, + queryParams: DEFAULT_QUERY_PARAMS, + }); + }); + }); + it('should filter by status: closed', async () => { appMockRenderer.render(); userEvent.click(screen.getByTestId('case-status-filter')); diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx index 6431ce23f5ec0..c08febcaaff91 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx @@ -32,7 +32,6 @@ jest.mock('../../containers/use_get_categories'); jest.mock('../../containers/user_profiles/use_suggest_user_profiles'); const onFilterChanged = jest.fn(); -const refetch = jest.fn(); const setFilterRefetch = jest.fn(); const props = { @@ -53,8 +52,11 @@ describe('CasesTableFilters ', () => { beforeEach(() => { appMockRender = createAppMockRenderer(); jest.clearAllMocks(); - (useGetTags as jest.Mock).mockReturnValue({ data: ['coke', 'pepsi'], refetch }); - (useGetCategories as jest.Mock).mockReturnValue({ data: ['twix', 'snickers'], refetch }); + (useGetTags as jest.Mock).mockReturnValue({ data: ['coke', 'pepsi'], isLoading: false }); + (useGetCategories as jest.Mock).mockReturnValue({ + data: ['twix', 'snickers'], + isLoading: false, + }); (useSuggestUserProfiles as jest.Mock).mockReturnValue({ data: userProfiles, isLoading: false }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx index 2f90aff69c60e..36384b9b4cd6f 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx @@ -188,10 +188,12 @@ describe('AssignUsers', () => { appMockRender.render(); fireEvent.mouseEnter( - screen.getByTestId(`user-profile-assigned-user-group-${userProfiles[0].user.username}`) + screen.getByTestId(`user-profile-assigned-user-${userProfiles[0].user.username}-remove-group`) ); fireEvent.click( - screen.getByTestId(`user-profile-assigned-user-cross-${userProfiles[0].user.username}`) + screen.getByTestId( + `user-profile-assigned-user-${userProfiles[0].user.username}-remove-button` + ) ); await waitFor(() => expect(onAssigneesChanged).toBeCalledTimes(1)); @@ -268,8 +270,8 @@ describe('AssignUsers', () => { }; appMockRender.render(); - fireEvent.mouseEnter(screen.getByTestId(`user-profile-assigned-user-group-unknownId1`)); - fireEvent.click(screen.getByTestId(`user-profile-assigned-user-cross-unknownId1`)); + fireEvent.mouseEnter(screen.getByTestId(`user-profile-assigned-user-unknownId1-remove-group`)); + fireEvent.click(screen.getByTestId(`user-profile-assigned-user-unknownId1-remove-button`)); await waitFor(() => expect(onAssigneesChanged).toBeCalledTimes(1)); @@ -291,8 +293,12 @@ describe('AssignUsers', () => { appMockRender.render(); expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); - expect(screen.getByTestId('user-profile-assigned-user-group-unknownId1')).toBeInTheDocument(); - expect(screen.getByTestId('user-profile-assigned-user-group-unknownId2')).toBeInTheDocument(); + expect( + screen.getByTestId('user-profile-assigned-user-unknownId1-remove-group') + ).toBeInTheDocument(); + expect( + screen.getByTestId('user-profile-assigned-user-unknownId2-remove-group') + ).toBeInTheDocument(); }); it('calls onAssigneesChanged with both users with profiles and without', async () => { diff --git a/x-pack/plugins/cases/public/components/case_view/components/edit_category.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/edit_category.test.tsx index 0f5846fcd610d..9e5db12f98ff1 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/edit_category.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/edit_category.test.tsx @@ -104,6 +104,11 @@ describe('EditCategory ', () => { }); userEvent.type(screen.getByRole('combobox'), `${categories[0]}{enter}`); + + await waitFor(() => { + expect(screen.getByTestId('edit-category-submit')).not.toBeDisabled(); + }); + userEvent.click(screen.getByTestId('edit-category-submit')); await waitFor(() => expect(onSubmit).toBeCalledWith(categories[0])); @@ -119,6 +124,11 @@ describe('EditCategory ', () => { }); userEvent.type(screen.getByRole('combobox'), 'new{enter}'); + + await waitFor(() => { + expect(screen.getByTestId('edit-category-submit')).not.toBeDisabled(); + }); + userEvent.click(screen.getByTestId('edit-category-submit')); await waitFor(() => expect(onSubmit).toBeCalledWith('new')); @@ -159,11 +169,63 @@ describe('EditCategory ', () => { }); userEvent.click(screen.getByTestId('comboBoxClearButton')); + + await waitFor(() => { + expect(screen.getByTestId('edit-category-submit')).not.toBeDisabled(); + }); + userEvent.click(screen.getByTestId('edit-category-submit')); await waitFor(() => expect(onSubmit).toBeCalledWith(null)); }); + it('should disabled the save button on error', async () => { + const bigCategory = 'a'.repeat(51); + + appMockRender.render(); + + userEvent.click(screen.getByTestId('category-edit-button')); + + await waitFor(() => { + expect(screen.getByTestId('categories-list')).toBeInTheDocument(); + }); + + userEvent.type(screen.getByRole('combobox'), `${bigCategory}{enter}`); + + await waitFor(() => { + expect( + screen.getByText('The length of the category is too long. The maximum length is 50.') + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId('edit-category-submit')).toBeDisabled(); + }); + + it('should disabled the save button on empty state', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('category-edit-button')); + + await waitFor(() => { + expect(screen.getByTestId('categories-list')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('edit-category-submit')).toBeDisabled(); + }); + + it('should disabled the save button when not changing category', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('category-edit-button')); + + await waitFor(() => { + expect(screen.getByTestId('categories-list')).toBeInTheDocument(); + expect(screen.getByText('My category')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('edit-category-submit')).toBeDisabled(); + }); + it('does not show edit button when the user does not have update permissions', () => { const newAppMockRenderer = createAppMockRenderer({ permissions: readCasesPermissions() }); @@ -171,4 +233,40 @@ describe('EditCategory ', () => { expect(screen.queryByTestId('category-edit-button')).not.toBeInTheDocument(); }); + + it('should set the category correctly if it is updated', async () => { + const { rerender } = appMockRender.render( + + ); + + userEvent.click(screen.getByTestId('category-edit-button')); + + await waitFor(() => { + expect(screen.getByTestId('categories-list')).toBeInTheDocument(); + expect(screen.getByText('My category')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('edit-category-cancel')); + + rerender(); + + userEvent.click(screen.getByTestId('category-edit-button')); + + await waitFor(() => { + expect(screen.getByTestId('categories-list')).toBeInTheDocument(); + expect(screen.getByText('category from the API')).toBeInTheDocument(); + }); + }); + + it('removes the category correctly using the cross button', async () => { + appMockRender.render(); + + await waitFor(() => { + expect(screen.getByText('My category')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('category-remove-button')); + + await waitFor(() => expect(onSubmit).toBeCalledWith(null)); + }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/edit_category.tsx b/x-pack/plugins/cases/public/components/case_view/components/edit_category.tsx index b7055c7a3b857..df3c3e538e71a 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/edit_category.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/edit_category.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiText, EuiHorizontalRule, @@ -16,26 +16,66 @@ import { EuiButtonIcon, EuiLoadingSpinner, } from '@elastic/eui'; +import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { useGetCategories } from '../../../containers/use_get_categories'; import * as i18n from '../../category/translations'; import { CategoryViewer } from '../../category/category_viewer_component'; import { useCasesContext } from '../../cases_context/use_cases_context'; import { CategoryFormField } from '../../category/category_form_field'; +import { RemovableItem } from '../../removable_item/removable_item'; export interface EditCategoryProps { isLoading: boolean; - onSubmit: (category: string | null | undefined) => void; - category: string | null | undefined; + onSubmit: (category?: string | null) => void; + category?: string | null; } +interface CategoryFormState { + isValid: boolean | undefined; + submit: FormHook<{ category?: EditCategoryProps['category'] }>['submit']; +} + +type CategoryFormWrapper = Pick & { + availableCategories: string[]; + onChange?: (state: CategoryFormState) => void; +}; + +const CategoryFormWrapper: React.FC = ({ + category, + availableCategories, + isLoading, + onChange, +}) => { + const { form } = useForm({ + defaultValue: { category }, + }); + + const { submit, isValid: isFormValid } = form; + + useEffect(() => { + if (onChange) { + onChange({ isValid: isFormValid, submit }); + } + }, [isFormValid, onChange, submit]); + + return ( +
+ + + ); +}; + +CategoryFormWrapper.displayName = 'CategoryFormWrapper'; + export const EditCategory = React.memo(({ isLoading, onSubmit, category }: EditCategoryProps) => { const { permissions } = useCasesContext(); const [isEditCategory, setIsEditCategory] = useState(false); const { data: categories = [], isLoading: isLoadingCategories } = useGetCategories(); - const { form } = useForm({ - defaultValue: { category }, + const [formState, setFormState] = useState({ + isValid: undefined, + submit: async () => ({ isValid: false, data: {} }), }); const onEdit = () => { @@ -46,20 +86,25 @@ export const EditCategory = React.memo(({ isLoading, onSubmit, category }: EditC setIsEditCategory(false); }; + const removeCategory = () => { + onSubmit(null); + setIsEditCategory(false); + }; + const onSubmitCategory = async () => { - const { isValid, data } = await form.submit(); + const { isValid, data } = await formState.submit(); if (isValid) { const newCategory = data.category != null ? data.category : null; onSubmit(newCategory); - form.reset({ defaultValue: data }); } setIsEditCategory(false); }; const isLoadingAll = isLoading || isLoadingCategories; + const isCategoryValid = formState.isValid; return ( @@ -90,7 +135,14 @@ export const EditCategory = React.memo(({ isLoading, onSubmit, category }: EditC {!isEditCategory && ( {category ? ( - + + + ) : ( {i18n.NO_CATEGORIES} @@ -100,9 +152,12 @@ export const EditCategory = React.memo(({ isLoading, onSubmit, category }: EditC )} {isEditCategory && ( -
- - + @@ -113,6 +168,7 @@ export const EditCategory = React.memo(({ isLoading, onSubmit, category }: EditC iconType="save" onClick={onSubmitCategory} size="s" + disabled={!isCategoryValid || isLoadingAll} > {i18n.SAVE} diff --git a/x-pack/plugins/cases/public/components/category/category_component.test.tsx b/x-pack/plugins/cases/public/components/category/category_component.test.tsx index e9d67948f949e..281051f59f599 100644 --- a/x-pack/plugins/cases/public/components/category/category_component.test.tsx +++ b/x-pack/plugins/cases/public/components/category/category_component.test.tsx @@ -87,4 +87,20 @@ describe('Category ', () => { expect(screen.getByTestId('comboBoxInput')).toHaveTextContent('hi'); }); }); + + it('should add case sensitive text', async () => { + render(); + + userEvent.type(screen.getByRole('combobox'), 'hi{enter}'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith('hi'); + }); + + userEvent.type(screen.getByRole('combobox'), 'Hi{enter}'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith('Hi'); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/category/category_component.tsx b/x-pack/plugins/cases/public/components/category/category_component.tsx index 960141cf82747..ee6f84a244062 100644 --- a/x-pack/plugins/cases/public/components/category/category_component.tsx +++ b/x-pack/plugins/cases/public/components/category/category_component.tsx @@ -65,6 +65,7 @@ export const CategoryComponent: React.FC = React.memo( aria-label="categories-list" isClearable customOptionText={ADD_CATEGORY_CUSTOM_OPTION_LABEL_COMBO_BOX} + isCaseSensitive /> ); } diff --git a/x-pack/plugins/cases/public/components/category/category_form_field.test.tsx b/x-pack/plugins/cases/public/components/category/category_form_field.test.tsx index 03f6bf9444591..3e6eb6472bf9a 100644 --- a/x-pack/plugins/cases/public/components/category/category_form_field.test.tsx +++ b/x-pack/plugins/cases/public/components/category/category_form_field.test.tsx @@ -9,31 +9,17 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import { CategoryFormField } from './category_form_field'; import { categories } from '../../containers/mock'; -import { EuiButton } from '@elastic/eui'; import { MAX_CATEGORY_LENGTH } from '../../../common/constants'; +import { FormTestComponent } from '../../common/test_utils'; describe('Category', () => { let appMockRender: AppMockRenderer; const onSubmit = jest.fn(); - const FormComponent: React.FC<{ category?: string | null }> = ({ children, category }) => { - const defaultValue = category !== undefined ? { defaultValue: { category } } : {}; - - const { form } = useForm({ onSubmit, ...defaultValue }); - - return ( -
- {children} - form.submit()}>{'Submit'} -
- ); - }; - beforeEach(() => { jest.clearAllMocks(); appMockRender = createAppMockRenderer(); @@ -41,9 +27,9 @@ describe('Category', () => { it('renders the category field correctly', () => { appMockRender.render( - + - + ); expect(screen.getByTestId('categories-list')).toBeInTheDocument(); @@ -51,9 +37,9 @@ describe('Category', () => { it('can submit without setting a category', async () => { appMockRender.render( - + - + ); expect(screen.getByTestId('categories-list')).toBeInTheDocument(); @@ -67,9 +53,9 @@ describe('Category', () => { it('can submit with category a string as default value', async () => { appMockRender.render( - + - + ); expect(screen.getByTestId('categories-list')).toBeInTheDocument(); @@ -83,9 +69,9 @@ describe('Category', () => { it('can submit with category with null as default value', async () => { appMockRender.render( - + - + ); expect(screen.getByTestId('categories-list')).toBeInTheDocument(); @@ -99,9 +85,9 @@ describe('Category', () => { it('cannot submit if the category is an empty string', async () => { appMockRender.render( - + - + ); expect(screen.getByTestId('categories-list')).toBeInTheDocument(); @@ -120,9 +106,9 @@ describe('Category', () => { const category = 'a'.repeat(MAX_CATEGORY_LENGTH + 1); appMockRender.render( - + - + ); expect(screen.getByTestId('categories-list')).toBeInTheDocument(); @@ -139,9 +125,9 @@ describe('Category', () => { it('can set a category from existing ones', async () => { appMockRender.render( - + - + ); userEvent.type(screen.getByRole('combobox'), `${categories[1]}{enter}`); @@ -155,9 +141,9 @@ describe('Category', () => { it('can set a new category', async () => { appMockRender.render( - + - + ); userEvent.type(screen.getByRole('combobox'), 'my new category{enter}'); @@ -171,9 +157,9 @@ describe('Category', () => { it('cannot set an empty category', async () => { appMockRender.render( - + - + ); userEvent.type(screen.getByRole('combobox'), ' {enter}'); @@ -186,11 +172,11 @@ describe('Category', () => { }); }); - it('setting an empty and clear it do not produce an error', async () => { + it('setting an empty category and clear it do not produce an error', async () => { appMockRender.render( - + - + ); userEvent.type(screen.getByRole('combobox'), ' {enter}'); @@ -212,9 +198,9 @@ describe('Category', () => { it('disables the component correctly when it is loading', () => { appMockRender.render( - + - + ); expect(screen.getByRole('combobox')).toBeDisabled(); diff --git a/x-pack/plugins/cases/public/components/category/category_viewer_component.test.tsx b/x-pack/plugins/cases/public/components/category/category_viewer_component.test.tsx index 2a57409131555..9d93ddd3eae92 100644 --- a/x-pack/plugins/cases/public/components/category/category_viewer_component.test.tsx +++ b/x-pack/plugins/cases/public/components/category/category_viewer_component.test.tsx @@ -12,9 +12,6 @@ import { CategoryViewer } from './category_viewer_component'; describe('Category viewer ', () => { const sampleCategory = 'foobar'; - beforeEach(() => { - jest.resetAllMocks(); - }); it('renders category', () => { render(); diff --git a/x-pack/plugins/cases/public/components/category/translations.ts b/x-pack/plugins/cases/public/components/category/translations.ts index 492f5ab8233da..5629795103d8b 100644 --- a/x-pack/plugins/cases/public/components/category/translations.ts +++ b/x-pack/plugins/cases/public/components/category/translations.ts @@ -19,3 +19,14 @@ export const EMPTY_CATEGORY_VALIDATION_MSG = i18n.translate( defaultMessage: 'Empty category is not allowed', } ); + +export const REMOVE_CATEGORY = i18n.translate('xpack.cases.caseView.removeCategory', { + defaultMessage: 'Remove category', +}); + +export const REMOVE_CATEGORY_ARIA_LABEL = i18n.translate( + 'xpack.cases.caseView.removeCategoryAriaLabel', + { + defaultMessage: 'click to remove category', + } +); diff --git a/x-pack/plugins/cases/public/components/removable_item/removable_item.test.tsx b/x-pack/plugins/cases/public/components/removable_item/removable_item.test.tsx new file mode 100644 index 0000000000000..6408c8c962023 --- /dev/null +++ b/x-pack/plugins/cases/public/components/removable_item/removable_item.test.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 from 'react'; +import type { AppMockRenderer } from '../../common/mock'; +import { noUpdateCasesPermissions, createAppMockRenderer } from '../../common/mock'; +import { fireEvent, screen } from '@testing-library/react'; +import { RemovableItem } from './removable_item'; +import userEvent from '@testing-library/user-event'; + +const MockComponent = () => { + return {'My component'}; +}; + +describe('UserRepresentation', () => { + let appMockRender: AppMockRenderer; + + const onRemoveItem = jest.fn(); + + const defaultProps = { + tooltipContent: 'Remove item', + buttonAriaLabel: 'Remove item', + onRemoveItem, + }; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + + it('does not show the cross button when the user is not hovering over the row', () => { + appMockRender.render( + + + + ); + + expect(screen.queryByTestId('remove-button')).toHaveStyle('opacity: 0'); + }); + + it('show the cross button when the user is hovering over the row', () => { + appMockRender.render( + + + + ); + + fireEvent.mouseEnter(screen.getByTestId('remove-group')); + + expect(screen.getByTestId('remove-button')).toHaveStyle('opacity: 1'); + }); + + it('shows and then removes the cross button when the user hovers and removes the mouse from over the row', () => { + appMockRender.render( + + + + ); + + fireEvent.mouseEnter(screen.getByTestId('remove-group')); + expect(screen.getByTestId('remove-button')).toHaveStyle('opacity: 1'); + + fireEvent.mouseLeave(screen.getByTestId('remove-group')); + expect(screen.queryByTestId('remove-button')).toHaveStyle('opacity: 0'); + }); + + it('does not show the cross button when the user is hovering over the row and does not have update permissions', () => { + appMockRender = createAppMockRenderer({ permissions: noUpdateCasesPermissions() }); + appMockRender.render( + + + + ); + + fireEvent.mouseEnter(screen.getByTestId('remove-group')); + + expect(screen.queryByTestId('remove-button')).not.toBeInTheDocument(); + }); + + it('call onRemoveItem correctly', () => { + appMockRender.render( + + + + ); + + userEvent.click(screen.getByTestId('remove-button')); + + expect(onRemoveItem).toBeCalled(); + }); + + it('sets the dataTestSubjPrefix correctly', () => { + appMockRender.render( + + + + ); + + expect(screen.getByTestId('my-prefix-remove-group')).toBeInTheDocument(); + expect(screen.getByTestId('my-prefix-remove-button')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/removable_item/removable_item.tsx b/x-pack/plugins/cases/public/components/removable_item/removable_item.tsx new file mode 100644 index 0000000000000..8cc9138511086 --- /dev/null +++ b/x-pack/plugins/cases/public/components/removable_item/removable_item.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 { EuiFlexGroup, EuiFlexItem, EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import React, { useState } from 'react'; +import { useCasesContext } from '../cases_context/use_cases_context'; + +interface Props { + tooltipContent: string; + buttonAriaLabel: string; + onRemoveItem: () => void; + children: React.ReactNode; + dataTestSubjPrefix?: string; +} + +const RemovableItemComponent: React.FC = ({ + children, + tooltipContent, + buttonAriaLabel, + onRemoveItem, + dataTestSubjPrefix = '', +}) => { + const { permissions } = useCasesContext(); + const [isHovering, setIsHovering] = useState(false); + + const onFocus = () => setIsHovering(true); + const onFocusLeave = () => setIsHovering(false); + + const dataTestSubj = dataTestSubjPrefix.length > 0 ? `${dataTestSubjPrefix}-remove-` : 'remove-'; + + return ( + + {children} + {permissions.update && ( + + + + + + )} + + ); +}; + +RemovableItemComponent.displayName = 'RemovableItem'; + +export const RemovableItem = React.memo(RemovableItemComponent); diff --git a/x-pack/plugins/cases/public/components/user_actions/category.test.tsx b/x-pack/plugins/cases/public/components/user_actions/category.test.tsx index 04e3f3b82f452..edcf7620243b2 100644 --- a/x-pack/plugins/cases/public/components/user_actions/category.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/category.test.tsx @@ -41,7 +41,7 @@ describe('createCategoryUserActionBuilder ', () => { ); - expect(screen.getByTestId('category-update-user-action')).toBeInTheDocument(); + expect(screen.getByTestId('category-update-category-user-action-title')).toBeInTheDocument(); expect(screen.getByText('added the category "fantasy"')).toBeInTheDocument(); }); @@ -61,7 +61,7 @@ describe('createCategoryUserActionBuilder ', () => { ); - expect(screen.getByTestId('category-delete-user-action')).toBeInTheDocument(); + expect(screen.getByTestId('category-delete-category-user-action-title')).toBeInTheDocument(); expect(screen.getByText('removed the category')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/category.tsx b/x-pack/plugins/cases/public/components/user_actions/category.tsx index b69ff0321851e..b050c5a9e84f7 100644 --- a/x-pack/plugins/cases/public/components/user_actions/category.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/category.tsx @@ -21,7 +21,7 @@ const getLabelTitle = (userAction: UserActionResponse) => { {userAction.action === Actions.update ? ( diff --git a/x-pack/plugins/cases/public/components/user_profiles/removable_user.test.tsx b/x-pack/plugins/cases/public/components/user_profiles/removable_user.test.tsx index e94a78fdbaab3..4e892af864e96 100644 --- a/x-pack/plugins/cases/public/components/user_profiles/removable_user.test.tsx +++ b/x-pack/plugins/cases/public/components/user_profiles/removable_user.test.tsx @@ -14,10 +14,10 @@ import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer, noUpdateCasesPermissions } from '../../common/mock'; describe('UserRepresentation', () => { - const dataTestSubjGroup = `user-profile-assigned-user-group-${userProfiles[0].user.username}`; - const dataTestSubjCross = `user-profile-assigned-user-cross-${userProfiles[0].user.username}`; - const dataTestSubjGroupUnknown = `user-profile-assigned-user-group-unknownId`; - const dataTestSubjCrossUnknown = `user-profile-assigned-user-cross-unknownId`; + const dataTestSubjGroup = `user-profile-assigned-user-${userProfiles[0].user.username}-remove-group`; + const dataTestSubjCross = `user-profile-assigned-user-${userProfiles[0].user.username}-remove-button`; + const dataTestSubjGroupUnknown = `user-profile-assigned-user-unknownId-remove-group`; + const dataTestSubjCrossUnknown = `user-profile-assigned-user-unknownId-remove-button`; let defaultProps: UserRepresentationProps; let appMockRender: AppMockRenderer; diff --git a/x-pack/plugins/cases/public/components/user_profiles/removable_user.tsx b/x-pack/plugins/cases/public/components/user_profiles/removable_user.tsx index 72ee046e8936e..d98718d92626f 100644 --- a/x-pack/plugins/cases/public/components/user_profiles/removable_user.tsx +++ b/x-pack/plugins/cases/public/components/user_profiles/removable_user.tsx @@ -5,12 +5,11 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; -import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import React, { useCallback } from 'react'; import * as i18n from './translations'; import type { Assignee } from './types'; import { HoverableUserWithAvatar } from './hoverable_user_with_avatar'; -import { useCasesContext } from '../cases_context/use_cases_context'; +import { RemovableItem } from '../removable_item/removable_item'; export interface UserRepresentationProps { assignee: Assignee; @@ -21,55 +20,22 @@ const RemovableUserComponent: React.FC = ({ assignee, onRemoveAssignee, }) => { - const { permissions } = useCasesContext(); - const [isHovering, setIsHovering] = useState(false); - const removeAssigneeCallback = useCallback( () => onRemoveAssignee(assignee.uid), [onRemoveAssignee, assignee.uid] ); - const onFocus = useCallback(() => setIsHovering(true), []); - const onFocusLeave = useCallback(() => setIsHovering(false), []); - const usernameDataTestSubj = assignee.profile?.user.username ?? assignee.uid; return ( - - - - - {permissions.update && ( - - - - - - )} - + + ); }; diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index b8df4432d2dfa..8bc2284759980 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -438,6 +438,26 @@ describe('Cases API', () => { }); expect(resp).toEqual({ ...allCases }); }); + + it('should not send the category field if it is an empty array', async () => { + await getCases({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + category: [], + }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { + method: 'GET', + query: { + ...DEFAULT_QUERY_PARAMS, + searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + }, + signal: abortCtrl.signal, + }); + }); }); describe('getCasesStatus', () => { diff --git a/x-pack/plugins/cases/public/containers/use_get_categories.tsx b/x-pack/plugins/cases/public/containers/use_get_categories.tsx index 21f2ecc15cf50..893104991846f 100644 --- a/x-pack/plugins/cases/public/containers/use_get_categories.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_categories.tsx @@ -16,6 +16,7 @@ import * as i18n from './translations'; export const useGetCategories = () => { const { showErrorToast } = useCasesToast(); const { owner } = useCasesContext(); + return useQuery( casesQueriesKeys.categories(), () => { @@ -24,11 +25,7 @@ export const useGetCategories = () => { }, { onError: (error: ServerError) => { - if (error.name !== 'AbortError') { - showErrorToast(error.body && error.body.message ? new Error(error.body.message) : error, { - title: i18n.CATEGORIES_ERROR_TITLE, - }); - } + showErrorToast(error, { title: i18n.CATEGORIES_ERROR_TITLE }); }, staleTime: 60 * 1000, // one minute } diff --git a/x-pack/plugins/cases/server/client/utils.ts b/x-pack/plugins/cases/server/client/utils.ts index b630d940ac6b8..749d961766289 100644 --- a/x-pack/plugins/cases/server/client/utils.ts +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -456,6 +456,15 @@ export const getCaseToUpdate = ( { id: queryCase.id, version: queryCase.version } ); +/** + * TODO: Backend is not connected with the + * frontend in x-pack/plugins/cases/common/ui/types.ts. + * It is easy to forget to update a sort field. + * We should fix it and make it common. + * Also the sortField in x-pack/plugins/cases/common/api/cases/case.ts + * is set to string. We should narrow it to the + * acceptable values + */ enum SortFieldCase { closedAt = 'closed_at', createdAt = 'created_at', @@ -463,6 +472,7 @@ enum SortFieldCase { title = 'title.keyword', severity = 'severity', updatedAt = 'updated_at', + category = 'category', } export const convertSortField = (sortField: string | undefined): SortFieldCase => { @@ -482,6 +492,8 @@ export const convertSortField = (sortField: string | undefined): SortFieldCase = case 'updatedAt': case 'updated_at': return SortFieldCase.updatedAt; + case 'category': + return SortFieldCase.category; default: return SortFieldCase.createdAt; } diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts index 8823f5144a0fc..fe8ed483db24a 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts @@ -85,8 +85,8 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await header.waitUntilLoadingHasFinished(); await testSubjects.existOrFail('case-view-title'); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user2'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user-remove-group'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user2-remove-group'); }); }); }); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index d2d6d7b6a4396..dd584a51fa979 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -596,11 +596,11 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('shows the unknown assignee', async () => { - await testSubjects.existOrFail('user-profile-assigned-user-group-abc'); + await testSubjects.existOrFail('user-profile-assigned-user-abc-remove-group'); }); it('removes the unknown assignee when selecting the remove all users in the popover', async () => { - await testSubjects.existOrFail('user-profile-assigned-user-group-abc'); + await testSubjects.existOrFail('user-profile-assigned-user-abc-remove-group'); await cases.singleCase.openAssigneesPopover(); await cases.common.setSearchTextInAssigneesPopover('case'); @@ -608,7 +608,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await (await find.byButtonText('Remove all assignees')).click(); await cases.singleCase.closeAssigneesPopover(); - await testSubjects.missingOrFail('user-profile-assigned-user-group-abc'); + await testSubjects.missingOrFail('user-profile-assigned-user-abc-remove-group'); }); }); @@ -627,7 +627,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('assigns the case to the current user when clicking the assign to self link', async () => { await testSubjects.click('case-view-assign-yourself-link'); await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user-remove-group'); }); }); @@ -652,7 +652,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await cases.common.selectFirstRowInAssigneesPopover(); await cases.singleCase.closeAssigneesPopover(); await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user-remove-group'); }); it('assigns multiple users', async () => { @@ -662,8 +662,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await cases.singleCase.closeAssigneesPopover(); await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user2'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user-remove-group'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user2-remove-group'); }); }); @@ -678,17 +678,17 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // navigate out of the modal await cases.singleCase.closeAssigneesPopover(); await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user-remove-group'); // hover over the assigned user await ( await find.byCssSelector( - '[data-test-subj="user-profile-assigned-user-group-cases_all_user"]' + '[data-test-subj="user-profile-assigned-user-cases_all_user-remove-group"]' ) ).moveMouseTo(); // delete the user - await testSubjects.click('user-profile-assigned-user-cross-cases_all_user'); + await testSubjects.click('user-profile-assigned-user-cases_all_user-remove-button'); await testSubjects.existOrFail('case-view-assign-yourself-link'); }); From a705225f6f6f348f292e9098dfe6cfc7e271a56a Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Wed, 21 Jun 2023 12:12:49 +0100 Subject: [PATCH 12/31] [APM] Provide * to the params.environment field when environment_all (#160103) Bug fix for https://github.com/elastic/kibana/pull/159958#issuecomment-1599419095 ### What was done Provide * to the params.environment field when environment_all, actionable observability will handle it internally --- .../plugins/apm/public/components/shared/slo_callout/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/public/components/shared/slo_callout/index.tsx b/x-pack/plugins/apm/public/components/shared/slo_callout/index.tsx index fa74a2a361bcb..b9d609eee0772 100644 --- a/x-pack/plugins/apm/public/components/shared/slo_callout/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/slo_callout/index.tsx @@ -17,6 +17,7 @@ import { encode } from '@kbn/rison'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; interface Props { dismissCallout: () => void; @@ -48,7 +49,7 @@ export function SloCallout({ type: 'sli.apm.transactionErrorRate', params: { service: serviceName, - environment, + environment: environment === ENVIRONMENT_ALL.value ? '*' : environment, transactionName: transactionName ?? '', transactionType: transactionType ?? '', }, From 87b80cb21b86e3f118889ba9d2b7b8f09596734f Mon Sep 17 00:00:00 2001 From: Maryam Saeidi Date: Wed, 21 Jun 2023 13:25:51 +0200 Subject: [PATCH 13/31] [AO] Add data view to the new threshold rule (#159479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #158840 ## Summary This PR adds selecting a persisted data view and using it in the new threshold rule. |Flyout|Rule saved object| |---|---| |![image](https://github.com/elastic/kibana/assets/12370520/70bfa9d2-c4c6-44dd-bcca-ba7c83d2c433)|![image](https://github.com/elastic/kibana/assets/12370520/6f1115e2-e1f1-4348-b380-18b7ce2cacba)| ## 🧪 How to test - Create a threshold rule with a persisted data view - Make sure the related feature flag is configured: `xpack.observability.unsafe.thresholdRule.enabled: true` - Check whether the triggered alert matches the expectation related to that data view - Check the rule saved object to ensure data is saved there correctly ## What is not covered in this PR I will follow up on the following list in future PRs: - [Temporary data view](https://github.com/elastic/kibana/issues/159774) - [Initial loading](https://github.com/elastic/kibana/issues/159779) - [Setting a timeField beside the timestamp](https://github.com/elastic/kibana/issues/159777) - [Error handling](https://github.com/elastic/kibana/issues/159776) - [Testing](https://github.com/elastic/kibana/issues/159778) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../common/threshold_rule/types.ts | 81 ------ x-pack/plugins/observability/kibana.jsonc | 3 +- .../alert_details_app_section.test.tsx.snap | 5 +- .../expression_row.test.tsx.snap | 0 .../alert_details_app_section.test.tsx | 10 +- .../components/alert_details_app_section.tsx | 19 +- .../threshold/components/alert_flyout.tsx | 0 .../autocomplete_field/autocomplete_field.tsx | 0 .../components/autocomplete_field/index.ts | 0 .../autocomplete_field/suggestion_item.tsx | 0 .../criterion_preview_chart.tsx | 0 .../threshold_annotations.test.tsx | 0 .../threshold_annotations.tsx | 0 .../custom_equation_editor.stories.tsx | 0 .../custom_equation_editor.tsx | 0 .../components/custom_equation/index.tsx | 0 .../custom_equation/metric_row_controls.tsx | 0 .../custom_equation/metric_row_with_agg.tsx | 0 .../custom_equation/metric_row_with_count.tsx | 0 .../components/custom_equation/types.ts | 0 .../threshold/components/expression.test.tsx | 34 +-- .../threshold/components/expression.tsx | 145 +++++++---- .../components/expression_chart.test.tsx | 20 +- .../threshold/components/expression_chart.tsx | 4 - .../components/expression_row.test.tsx | 8 - .../threshold/components/expression_row.tsx | 20 -- .../threshold/components/group_by.tsx | 0 .../threshold/components/kuery_bar.tsx | 0 .../components/metrics_alert_dropdown.tsx | 0 .../threshold/components/series_chart.tsx | 0 .../components/threshold.stories.tsx | 0 .../threshold/components/threshold.test.tsx | 0 .../threshold/components/threshold.tsx | 0 .../components/triggers_actions_context.tsx | 0 .../threshold/components/validation.test.ts | 0 .../threshold/components/validation.tsx | 0 .../containers/with_kuery_autocompletion.tsx | 0 .../threshold/helpers/calculate_domain.ts | 0 .../helpers/corrected_percent_convert.test.ts | 0 .../helpers/corrected_percent_convert.ts | 0 .../helpers/create_formatter_for_metric.ts | 0 .../create_formatter_for_metrics.test.ts | 0 .../helpers/create_metric_label.test.ts | 0 .../threshold/helpers/create_metric_label.ts | 0 .../threshold/helpers/get_metric_id.ts | 0 .../threshold/helpers/kuery.ts | 0 .../threshold/helpers/metric_to_format.ts | 0 .../threshold/helpers/notifications.ts | 0 .../threshold/helpers/runtime_types.ts | 0 .../threshold/helpers/source_errors.ts | 0 .../threshold/helpers/use_alert_prefill.ts | 0 .../hooks/use_kibana_time_zone_setting.ts | 0 .../hooks/use_kibana_timefilter_time.tsx | 0 .../use_metric_threshold_alert_prefill.ts | 0 .../hooks/use_metrics_explorer_chart_data.ts | 12 +- .../hooks/use_metrics_explorer_data.test.tsx | 14 +- .../hooks/use_metrics_explorer_data.ts | 12 +- .../use_metrics_explorer_options.test.tsx | 0 .../hooks/use_metrics_explorer_options.ts | 0 .../threshold/hooks/use_tracked_promise.ts | 0 .../threshold/i18n_strings.ts | 0 .../threshold/lib/generate_unique_key.test.ts | 0 .../threshold/lib/generate_unique_key.ts | 0 .../lib/transform_metrics_explorer_data.ts | 0 .../threshold/mocks/metric_threshold_rule.ts | 0 .../threshold/rule_data_formatters.ts | 0 .../{pages => components}/threshold/types.ts | 6 +- .../public/pages/threshold/helpers/source.tsx | 142 ----------- .../observability/public/plugin.mock.tsx | 29 +++ x-pack/plugins/observability/public/plugin.ts | 2 + .../register_observability_rule_types.ts | 9 +- .../public/utils/metrics_explorer.ts | 2 +- .../threshold/register_threshold_rule_type.ts | 20 +- .../server/lib/rules/threshold/test_mocks.ts | 237 ------------------ .../lib/rules/threshold/threshold_executor.ts | 11 +- .../server/lib/rules/threshold/types.ts | 2 - x-pack/plugins/observability/tsconfig.json | 4 +- x-pack/plugins/stack_alerts/public/index.ts | 2 + .../data_view_select_popover.test.tsx | 21 +- .../components/data_view_select_popover.tsx | 10 +- .../search_source_expression_form.tsx | 6 +- 81 files changed, 231 insertions(+), 659 deletions(-) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap (92%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/__snapshots__/expression_row.test.tsx.snap (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/alert_details_app_section.test.tsx (91%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/alert_details_app_section.tsx (93%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/alert_flyout.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/autocomplete_field/autocomplete_field.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/autocomplete_field/index.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/autocomplete_field/suggestion_item.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/criterion_preview_chart/criterion_preview_chart.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/criterion_preview_chart/threshold_annotations.test.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/criterion_preview_chart/threshold_annotations.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/custom_equation/custom_equation_editor.stories.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/custom_equation/custom_equation_editor.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/custom_equation/index.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/custom_equation/metric_row_controls.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/custom_equation/metric_row_with_agg.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/custom_equation/metric_row_with_count.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/custom_equation/types.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/expression.test.tsx (81%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/expression.tsx (80%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/expression_chart.test.tsx (83%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/expression_chart.tsx (98%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/expression_row.test.tsx (93%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/expression_row.tsx (92%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/group_by.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/kuery_bar.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/metrics_alert_dropdown.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/series_chart.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/threshold.stories.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/threshold.test.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/threshold.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/triggers_actions_context.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/validation.test.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/components/validation.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/containers/with_kuery_autocompletion.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/calculate_domain.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/corrected_percent_convert.test.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/corrected_percent_convert.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/create_formatter_for_metric.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/create_formatter_for_metrics.test.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/create_metric_label.test.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/create_metric_label.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/get_metric_id.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/kuery.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/metric_to_format.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/notifications.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/runtime_types.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/source_errors.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/helpers/use_alert_prefill.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/hooks/use_kibana_time_zone_setting.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/hooks/use_kibana_timefilter_time.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/hooks/use_metric_threshold_alert_prefill.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/hooks/use_metrics_explorer_chart_data.ts (89%) rename x-pack/plugins/observability/public/{pages => components}/threshold/hooks/use_metrics_explorer_data.test.tsx (94%) rename x-pack/plugins/observability/public/{pages => components}/threshold/hooks/use_metrics_explorer_data.ts (90%) rename x-pack/plugins/observability/public/{pages => components}/threshold/hooks/use_metrics_explorer_options.test.tsx (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/hooks/use_metrics_explorer_options.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/hooks/use_tracked_promise.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/i18n_strings.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/lib/generate_unique_key.test.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/lib/generate_unique_key.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/lib/transform_metrics_explorer_data.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/mocks/metric_threshold_rule.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/rule_data_formatters.ts (100%) rename x-pack/plugins/observability/public/{pages => components}/threshold/types.ts (95%) delete mode 100644 x-pack/plugins/observability/public/pages/threshold/helpers/source.tsx delete mode 100644 x-pack/plugins/observability/server/lib/rules/threshold/test_mocks.ts diff --git a/x-pack/plugins/observability/common/threshold_rule/types.ts b/x-pack/plugins/observability/common/threshold_rule/types.ts index 0f9dac9f88bd3..0762fef113ead 100644 --- a/x-pack/plugins/observability/common/threshold_rule/types.ts +++ b/x-pack/plugins/observability/common/threshold_rule/types.ts @@ -6,7 +6,6 @@ */ import * as rt from 'io-ts'; -import { indexPatternRt } from '@kbn/io-ts-utils'; import { ML_ANOMALY_THRESHOLD } from '@kbn/ml-anomaly-utils/anomaly_threshold'; import { values } from 'lodash'; import { Color } from './color_palette'; @@ -94,68 +93,14 @@ export const logDataViewReferenceRT = rt.type({ dataViewId: rt.string, }); -export type LogDataViewReference = rt.TypeOf; - // Index name export const logIndexNameReferenceRT = rt.type({ type: rt.literal('index_name'), indexName: rt.string, }); -export type LogIndexNameReference = rt.TypeOf; export const logIndexReferenceRT = rt.union([logDataViewReferenceRT, logIndexNameReferenceRT]); -/** - * Properties that represent a full source configuration, which is the result of merging static values with - * saved values. - */ -const SourceConfigurationFieldsRT = rt.type({ - message: rt.array(rt.string), -}); -export const SourceConfigurationRT = rt.type({ - name: rt.string, - description: rt.string, - metricAlias: rt.string, - logIndices: logIndexReferenceRT, - inventoryDefaultView: rt.string, - metricsExplorerDefaultView: rt.string, - fields: SourceConfigurationFieldsRT, - logColumns: rt.array(SourceConfigurationColumnRuntimeType), - anomalyThreshold: rt.number, -}); - -export const metricsSourceConfigurationPropertiesRT = rt.strict({ - name: SourceConfigurationRT.props.name, - description: SourceConfigurationRT.props.description, - metricAlias: SourceConfigurationRT.props.metricAlias, - inventoryDefaultView: SourceConfigurationRT.props.inventoryDefaultView, - metricsExplorerDefaultView: SourceConfigurationRT.props.metricsExplorerDefaultView, - anomalyThreshold: rt.number, -}); - -export type MetricsSourceConfigurationProperties = rt.TypeOf< - typeof metricsSourceConfigurationPropertiesRT ->; - -export const partialMetricsSourceConfigurationReqPayloadRT = rt.partial({ - ...metricsSourceConfigurationPropertiesRT.type.props, - metricAlias: indexPatternRt, -}); - -export const partialMetricsSourceConfigurationPropertiesRT = rt.partial({ - ...metricsSourceConfigurationPropertiesRT.type.props, -}); - -export type PartialMetricsSourceConfigurationProperties = rt.TypeOf< - typeof partialMetricsSourceConfigurationPropertiesRT ->; - -const metricsSourceConfigurationOriginRT = rt.keyof({ - fallback: null, - internal: null, - stored: null, -}); - /** * Source status */ @@ -180,32 +125,6 @@ export const metricsSourceStatusRT = rt.strict({ export type MetricsSourceStatus = rt.TypeOf; -export const metricsSourceConfigurationRT = rt.exact( - rt.intersection([ - rt.type({ - id: rt.string, - origin: metricsSourceConfigurationOriginRT, - configuration: metricsSourceConfigurationPropertiesRT, - }), - rt.partial({ - updatedAt: rt.number, - version: rt.string, - status: metricsSourceStatusRT, - }), - ]) -); - -export type MetricsSourceConfiguration = rt.TypeOf; -export type PartialMetricsSourceConfiguration = DeepPartial; - -export const metricsSourceConfigurationResponseRT = rt.type({ - source: metricsSourceConfigurationRT, -}); - -export type MetricsSourceConfigurationResponse = rt.TypeOf< - typeof metricsSourceConfigurationResponseRT ->; - export enum Comparator { GT = '>', LT = '<', diff --git a/x-pack/plugins/observability/kibana.jsonc b/x-pack/plugins/observability/kibana.jsonc index 5c08133f15a95..a34eb3ffb0ae6 100644 --- a/x-pack/plugins/observability/kibana.jsonc +++ b/x-pack/plugins/observability/kibana.jsonc @@ -13,6 +13,7 @@ "charts", "data", "dataViews", + "dataViewEditor", "embeddable", "exploratoryView", "features", @@ -29,7 +30,7 @@ "visualizations" ], "optionalPlugins": ["discover", "home", "licensing", "usageCollection", "cloud", "spaces"], - "requiredBundles": ["data", "kibanaReact", "kibanaUtils", "unifiedSearch", "cloudChat", "spaces"], + "requiredBundles": ["data", "kibanaReact", "kibanaUtils", "unifiedSearch", "cloudChat", "stackAlerts", "spaces"], "extraPublicDirs": ["common"] } } diff --git a/x-pack/plugins/observability/public/pages/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap b/x-pack/plugins/observability/public/components/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap similarity index 92% rename from x-pack/plugins/observability/public/pages/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap rename to x-pack/plugins/observability/public/components/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap index 5ee10d2d3381e..f7ccb9d6715c1 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap +++ b/x-pack/plugins/observability/public/components/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap @@ -19,7 +19,7 @@ Array [ "chartType": "line", "derivedIndexPattern": Object { "fields": Array [], - "title": "metricbeat-*", + "title": "unknown-index", }, "expression": Object { "aggType": "count", @@ -35,9 +35,6 @@ Array [ "host.hostname", ], "hideTitle": true, - "source": Object { - "id": "default", - }, "timeRange": Object { "from": "2023-03-28T10:43:13.802Z", "to": "2023-03-29T13:14:09.581Z", diff --git a/x-pack/plugins/observability/public/pages/threshold/components/__snapshots__/expression_row.test.tsx.snap b/x-pack/plugins/observability/public/components/threshold/components/__snapshots__/expression_row.test.tsx.snap similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/__snapshots__/expression_row.test.tsx.snap rename to x-pack/plugins/observability/public/components/threshold/components/__snapshots__/expression_row.test.tsx.snap diff --git a/x-pack/plugins/observability/public/pages/threshold/components/alert_details_app_section.test.tsx b/x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.test.tsx similarity index 91% rename from x-pack/plugins/observability/public/pages/threshold/components/alert_details_app_section.test.tsx rename to x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.test.tsx index d372bdbc95bee..6e490bdb06da0 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/alert_details_app_section.test.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.test.tsx @@ -16,7 +16,7 @@ import { buildMetricThresholdAlert, buildMetricThresholdRule, } from '../mocks/metric_threshold_rule'; -import { AlertDetailsAppSection } from './alert_details_app_section'; +import AlertDetailsAppSection from './alert_details_app_section'; import { ExpressionChart } from './expression_chart'; const mockedChartStartContract = chartPluginMock.createStartContract(); @@ -43,14 +43,6 @@ jest.mock('../../../utils/kibana_react', () => ({ }), })); -jest.mock('../helpers/source', () => ({ - withSourceProvider: () => jest.fn, - useSourceContext: () => ({ - source: { id: 'default' }, - createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), - }), -})); - describe('AlertDetailsAppSection', () => { const queryClient = new QueryClient(); const mockedSetAlertSummaryFields = jest.fn(); diff --git a/x-pack/plugins/observability/public/pages/threshold/components/alert_details_app_section.tsx b/x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.tsx similarity index 93% rename from x-pack/plugins/observability/public/pages/threshold/components/alert_details_app_section.tsx rename to x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.tsx index b5ff1d62c2b54..1995afa1923a2 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/alert_details_app_section.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { DataViewBase } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { useEffect, useMemo } from 'react'; @@ -35,7 +36,6 @@ import { ExpressionChart } from './expression_chart'; import { TIME_LABELS } from './criterion_preview_chart/criterion_preview_chart'; import { Threshold } from './threshold'; import { MetricsExplorerChartType } from '../hooks/use_metrics_explorer_options'; -import { useSourceContext, withSourceProvider } from '../helpers/source'; import { MetricThresholdRuleTypeParams } from '../types'; // TODO Use a generic props for app sections https://github.com/elastic/kibana/issues/152690 @@ -58,19 +58,23 @@ interface AppSectionProps { setAlertSummaryFields: React.Dispatch>; } -export function AlertDetailsAppSection({ +// eslint-disable-next-line import/no-default-export +export default function AlertDetailsAppSection({ alert, rule, ruleLink, setAlertSummaryFields, }: AppSectionProps) { const { uiSettings, charts } = useKibana().services; - const { source, createDerivedIndexPattern } = useSourceContext(); const { euiTheme } = useEuiTheme(); - const derivedIndexPattern = useMemo( - () => createDerivedIndexPattern(), - [createDerivedIndexPattern] + // TODO Use rule data view + const derivedIndexPattern = useMemo( + () => ({ + fields: [], + title: 'unknown-index', + }), + [] ); const chartProps = { theme: charts.theme.useChartsTheme(), @@ -162,7 +166,6 @@ export function AlertDetailsAppSection({ filterQuery={rule.params.filterQueryText} groupBy={rule.params.groupBy} hideTitle - source={source} timeRange={timeRange} />
@@ -173,5 +176,3 @@ export function AlertDetailsAppSection({
) : null; } -// eslint-disable-next-line import/no-default-export -export default withSourceProvider(AlertDetailsAppSection)('default'); diff --git a/x-pack/plugins/observability/public/pages/threshold/components/alert_flyout.tsx b/x-pack/plugins/observability/public/components/threshold/components/alert_flyout.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/alert_flyout.tsx rename to x-pack/plugins/observability/public/components/threshold/components/alert_flyout.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/autocomplete_field/autocomplete_field.tsx b/x-pack/plugins/observability/public/components/threshold/components/autocomplete_field/autocomplete_field.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/autocomplete_field/autocomplete_field.tsx rename to x-pack/plugins/observability/public/components/threshold/components/autocomplete_field/autocomplete_field.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/autocomplete_field/index.ts b/x-pack/plugins/observability/public/components/threshold/components/autocomplete_field/index.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/autocomplete_field/index.ts rename to x-pack/plugins/observability/public/components/threshold/components/autocomplete_field/index.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/components/autocomplete_field/suggestion_item.tsx b/x-pack/plugins/observability/public/components/threshold/components/autocomplete_field/suggestion_item.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/autocomplete_field/suggestion_item.tsx rename to x-pack/plugins/observability/public/components/threshold/components/autocomplete_field/suggestion_item.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/criterion_preview_chart/criterion_preview_chart.tsx b/x-pack/plugins/observability/public/components/threshold/components/criterion_preview_chart/criterion_preview_chart.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/criterion_preview_chart/criterion_preview_chart.tsx rename to x-pack/plugins/observability/public/components/threshold/components/criterion_preview_chart/criterion_preview_chart.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/criterion_preview_chart/threshold_annotations.test.tsx b/x-pack/plugins/observability/public/components/threshold/components/criterion_preview_chart/threshold_annotations.test.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/criterion_preview_chart/threshold_annotations.test.tsx rename to x-pack/plugins/observability/public/components/threshold/components/criterion_preview_chart/threshold_annotations.test.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/criterion_preview_chart/threshold_annotations.tsx b/x-pack/plugins/observability/public/components/threshold/components/criterion_preview_chart/threshold_annotations.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/criterion_preview_chart/threshold_annotations.tsx rename to x-pack/plugins/observability/public/components/threshold/components/criterion_preview_chart/threshold_annotations.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/custom_equation_editor.stories.tsx b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/custom_equation_editor.stories.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/custom_equation_editor.stories.tsx rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/custom_equation_editor.stories.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/custom_equation_editor.tsx b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/custom_equation_editor.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/custom_equation_editor.tsx rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/custom_equation_editor.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/index.tsx b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/index.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/index.tsx rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/index.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/metric_row_controls.tsx b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/metric_row_controls.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/metric_row_controls.tsx rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/metric_row_controls.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/metric_row_with_agg.tsx b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/metric_row_with_agg.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/metric_row_with_agg.tsx rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/metric_row_with_agg.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/metric_row_with_count.tsx b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/metric_row_with_count.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/metric_row_with_count.tsx rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/metric_row_with_count.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/types.ts b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/types.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/types.ts rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/types.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/components/expression.test.tsx b/x-pack/plugins/observability/public/components/threshold/components/expression.test.tsx similarity index 81% rename from x-pack/plugins/observability/public/pages/threshold/components/expression.test.tsx rename to x-pack/plugins/observability/public/components/threshold/components/expression.test.tsx index e87c3041c9284..692ffc9539ab2 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/expression.test.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/expression.test.tsx @@ -5,34 +5,35 @@ * 2.0. */ +import { useKibana } from '../../../utils/kibana_react'; +import { kibanaStartMock } from '../../../utils/kibana_react.mock'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import React from 'react'; import { act } from 'react-dom/test-utils'; -// We are using this inside a `jest.mock` call. Jest requires dynamic dependencies to be prefixed with `mock` -import { coreMock as mockCoreMock } from '@kbn/core/public/mocks'; -import { Expressions } from './expression'; +import Expressions from './expression'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { MetricsExplorerMetric } from '../../../../common/threshold_rule/metrics_explorer'; import { Comparator } from '../../../../common/threshold_rule/types'; -jest.mock('../helpers/source', () => ({ - withSourceProvider: () => jest.fn, - useSourceContext: () => ({ - source: { id: 'default' }, - createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), - }), -})); +jest.mock('../../../utils/kibana_react'); -jest.mock('../../../utils/kibana_react', () => ({ - useKibana: () => ({ - services: mockCoreMock.createStart(), - }), -})); +const useKibanaMock = useKibana as jest.Mock; + +const mockKibana = () => { + useKibanaMock.mockReturnValue({ + ...kibanaStartMock.startContract(), + }); +}; const dataViewMock = dataViewPluginMocks.createStartContract(); describe('Expression', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockKibana(); + }); + async function setup(currentOptions: { metrics?: MetricsExplorerMetric[]; filterQuery?: string; @@ -43,6 +44,7 @@ describe('Expression', () => { groupBy: undefined, filterQueryText: '', sourceId: 'default', + searchConfiguration: {}, }; const wrapper = mountWithIntl( { setRuleProperty={() => {}} metadata={{ currentOptions, + adHocDataViewList: [], }} dataViews={dataViewMock} + onChangeMetaData={jest.fn()} /> ); diff --git a/x-pack/plugins/observability/public/pages/threshold/components/expression.tsx b/x-pack/plugins/observability/public/components/threshold/components/expression.tsx similarity index 80% rename from x-pack/plugins/observability/public/pages/threshold/components/expression.tsx rename to x-pack/plugins/observability/public/components/threshold/components/expression.tsx index 7bc42ccd984ba..703b30fe64519 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/expression.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/expression.tsx @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import React, { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { EuiAccordion, @@ -18,6 +19,10 @@ import { EuiText, EuiToolTip, } from '@elastic/eui'; +import { ISearchSource } from '@kbn/data-plugin/common'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { DataViewBase } from '@kbn/es-query'; +import { DataViewSelectPopover } from '@kbn/stack-alerts-plugin/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { debounce } from 'lodash'; @@ -36,13 +41,12 @@ import { ExpressionRow } from './expression_row'; import { MetricsExplorerKueryBar } from './kuery_bar'; import { MetricsExplorerOptions } from '../hooks/use_metrics_explorer_options'; import { convertKueryToElasticSearchQuery } from '../helpers/kuery'; -import { useSourceContext, withSourceProvider } from '../helpers/source'; import { MetricsExplorerGroupBy } from './group_by'; const FILTER_TYPING_DEBOUNCE_MS = 500; type Props = Omit< RuleTypeParamsExpressionProps, - 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' | 'onChangeMetaData' + 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' >; export const defaultExpression = { @@ -53,18 +57,55 @@ export const defaultExpression = { timeUnit: 'm', } as MetricExpression; -export function Expressions(props: Props) { - const { setRuleParams, ruleParams, errors, metadata } = props; - const { docLinks } = useKibana().services; - const { source, createDerivedIndexPattern } = useSourceContext(); +// eslint-disable-next-line import/no-default-export +export default function Expressions(props: Props) { + const { setRuleParams, ruleParams, errors, metadata, onChangeMetaData } = props; + const { data, dataViews, dataViewEditor, docLinks } = useKibana().services; const [timeSize, setTimeSize] = useState(1); const [timeUnit, setTimeUnit] = useState('m'); - const derivedIndexPattern = useMemo( - () => createDerivedIndexPattern(), - [createDerivedIndexPattern] + const [dataView, setDataView] = useState(); + const [searchSource, setSearchSource] = useState(); + const derivedIndexPattern = useMemo( + () => ({ + fields: dataView?.fields || [], + title: dataView?.getIndexPattern() || 'unknown-index', + }), + [dataView] ); + useEffect(() => { + const initSearchSource = async () => { + let initialSearchConfiguration = ruleParams.searchConfiguration; + + if (!ruleParams.searchConfiguration) { + const newSearchSource = data.search.searchSource.createEmpty(); + newSearchSource.setField('query', data.query.queryString.getDefaultQuery()); + const defaultDataView = await data.dataViews.getDefaultDataView(); + if (defaultDataView) { + newSearchSource.setField('index', defaultDataView); + setDataView(defaultDataView); + } + initialSearchConfiguration = newSearchSource.getSerializedFields(); + } + + try { + const createdSearchSource = await data.search.searchSource.create( + initialSearchConfiguration + ); + setRuleParams('searchConfiguration', initialSearchConfiguration); + setSearchSource(createdSearchSource); + setDataView(createdSearchSource.getField('index')); + } catch (error) { + // TODO Handle error + console.log('error:', error); + } + }; + + initSearchSource(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data.search.searchSource, data.dataViews]); + const options = useMemo(() => { if (metadata?.currentOptions?.metrics) { return metadata.currentOptions as MetricsExplorerOptions; @@ -76,31 +117,49 @@ export function Expressions(props: Props) { } }, [metadata]); + const onSelectDataView = useCallback( + (newDataView: DataView) => { + const ruleCriteria = (ruleParams.criteria ? ruleParams.criteria.slice() : []).map( + (criterion) => { + criterion.customMetrics?.forEach((metric) => { + metric.field = undefined; + }); + return criterion; + } + ); + setRuleParams('criteria', ruleCriteria); + searchSource?.setParent(undefined).setField('index', newDataView); + setRuleParams('searchConfiguration', searchSource?.getSerializedFields()); + setDataView(newDataView); + }, + [ruleParams.criteria, searchSource, setRuleParams] + ); + const updateParams = useCallback( (id, e: MetricExpression) => { - const exp = ruleParams.criteria ? ruleParams.criteria.slice() : []; - exp[id] = e; - setRuleParams('criteria', exp); + const ruleCriteria = ruleParams.criteria ? ruleParams.criteria.slice() : []; + ruleCriteria[id] = e; + setRuleParams('criteria', ruleCriteria); }, [setRuleParams, ruleParams.criteria] ); const addExpression = useCallback(() => { - const exp = ruleParams.criteria?.slice() || []; - exp.push({ + const ruleCriteria = ruleParams.criteria?.slice() || []; + ruleCriteria.push({ ...defaultExpression, timeSize: timeSize ?? defaultExpression.timeSize, timeUnit: timeUnit ?? defaultExpression.timeUnit, }); - setRuleParams('criteria', exp); + setRuleParams('criteria', ruleCriteria); }, [setRuleParams, ruleParams.criteria, timeSize, timeUnit]); const removeExpression = useCallback( (id: number) => { - const exp = ruleParams.criteria?.slice() || []; - if (exp.length > 1) { - exp.splice(id, 1); - setRuleParams('criteria', exp); + const ruleCriteria = ruleParams.criteria?.slice() || []; + if (ruleCriteria.length > 1) { + ruleCriteria.splice(id, 1); + setRuleParams('criteria', ruleCriteria); } }, [setRuleParams, ruleParams.criteria] @@ -143,26 +202,25 @@ export function Expressions(props: Props) { const updateTimeSize = useCallback( (ts: number | undefined) => { - const criteria = + const ruleCriteria = ruleParams.criteria?.map((c) => ({ ...c, timeSize: ts, })) || []; setTimeSize(ts || undefined); - setRuleParams('criteria', criteria); + setRuleParams('criteria', ruleCriteria); }, [ruleParams.criteria, setRuleParams] ); const updateTimeUnit = useCallback( (tu: string) => { - const criteria = - ruleParams.criteria?.map((c) => ({ - ...c, - timeUnit: tu, - })) || []; + const ruleCriteria = (ruleParams.criteria?.map((c) => ({ + ...c, + timeUnit: tu, + })) || []) as AlertParams['criteria']; setTimeUnit(tu as TimeUnitChar); - setRuleParams('criteria', criteria as AlertParams['criteria']); + setRuleParams('criteria', ruleCriteria); }, [ruleParams.criteria, setRuleParams] ); @@ -230,17 +288,13 @@ export function Expressions(props: Props) { preFillAlertGroupBy(); } - if (!ruleParams.sourceId) { - setRuleParams('sourceId', source?.id || 'default'); - } - if (typeof ruleParams.alertOnNoData === 'undefined') { setRuleParams('alertOnNoData', true); } if (typeof ruleParams.alertOnGroupDisappear === 'undefined') { setRuleParams('alertOnGroupDisappear', true); } - }, [metadata, source]); // eslint-disable-line react-hooks/exhaustive-deps + }, [metadata]); // eslint-disable-line react-hooks/exhaustive-deps const handleFieldSearchChange = useCallback( (e: ChangeEvent) => onFilterChange(e.target.value), @@ -283,7 +337,15 @@ export function Expressions(props: Props) { return ( <> - + { + onChangeMetaData({ ...metadata, adHocDataViewList }); + }} + /> +

1) || false} - fields={derivedIndexPattern.fields} + fields={derivedIndexPattern.fields as any} remove={removeExpression} addExpression={addExpression} key={idx} // idx's don't usually make good key's but here the index has semantic meaning @@ -308,17 +370,16 @@ export function Expressions(props: Props) { expression={e || {}} dataView={derivedIndexPattern} > + {/* Preview */} ); })} -
-
- - - {(metadata && ( + {(metadata && derivedIndexPattern && ( )} - (Expressions)('default'); diff --git a/x-pack/plugins/observability/public/pages/threshold/components/expression_chart.test.tsx b/x-pack/plugins/observability/public/components/threshold/components/expression_chart.test.tsx similarity index 83% rename from x-pack/plugins/observability/public/pages/threshold/components/expression_chart.test.tsx rename to x-pack/plugins/observability/public/components/threshold/components/expression_chart.test.tsx index 88d57b8688972..c4314aeb6b665 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/expression_chart.test.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/expression_chart.test.tsx @@ -14,11 +14,7 @@ import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { coreMock as mockCoreMock } from '@kbn/core/public/mocks'; import { MetricExpression } from '../types'; import { ExpressionChart } from './expression_chart'; -import { - Aggregators, - Comparator, - MetricsSourceConfiguration, -} from '../../../../common/threshold_rule/types'; +import { Aggregators, Comparator } from '../../../../common/threshold_rule/types'; const mockStartServices = mockCoreMock.createStart(); @@ -57,24 +53,10 @@ describe('ExpressionChart', () => { fields: [], }; - const source: MetricsSourceConfiguration = { - id: 'default', - origin: 'fallback', - configuration: { - name: 'default', - description: 'The default configuration', - metricAlias: 'metricbeat-*', - inventoryDefaultView: 'host', - metricsExplorerDefaultView: 'host', - anomalyThreshold: 20, - }, - }; - const wrapper = mountWithIntl( ({ - withSourceProvider: () => jest.fn, - useSourceContext: () => ({ - source: { id: 'default' }, - createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), - }), -})); - describe('ExpressionRow', () => { async function setup(expression: MetricExpression) { const wrapper = mountWithIntl( diff --git a/x-pack/plugins/observability/public/pages/threshold/components/expression_row.tsx b/x-pack/plugins/observability/public/components/threshold/components/expression_row.tsx similarity index 92% rename from x-pack/plugins/observability/public/pages/threshold/components/expression_row.tsx rename to x-pack/plugins/observability/public/components/threshold/components/expression_row.tsx index 08844ec7a9fd7..4bca96a8da3f6 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/expression_row.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/expression_row.tsx @@ -144,20 +144,6 @@ export const ExpressionRow: React.FC = (props) => { name: f.name, })); - // for v8.9 we want to show only the Custom Equation. Use EuiExpression instead of WhenExpression */ - // const updateAggType = useCallback( - // (at: string) => { - // setRuleParams(expressionId, { - // ...expression, - // aggType: at as MetricExpression['aggType'], - // metric: ['custom', 'count'].includes(at) ? undefined : expression.metric, - // customMetrics: at === 'custom' ? expression.customMetrics : undefined, - // equation: at === 'custom' ? expression.equation : undefined, - // label: at === 'custom' ? expression.label : undefined, - // }); - // }, - // [expressionId, expression, setRuleParams] - // ); return ( <> @@ -177,12 +163,6 @@ export const ExpressionRow: React.FC = (props) => { - {/* for v8.9 we want to show only the Custom Equation. Use EuiExpression instead of WhenExpression */} - {/* */} { diff --git a/x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_data.test.tsx b/x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_data.test.tsx similarity index 94% rename from x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_data.test.tsx rename to x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_data.test.tsx index d2c8955e00913..20a7e6213aefa 100644 --- a/x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_data.test.tsx +++ b/x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_data.test.tsx @@ -17,13 +17,11 @@ import { MetricsExplorerTimestampsRT, } from './use_metrics_explorer_options'; import { DataViewBase } from '@kbn/es-query'; -import { MetricsSourceConfigurationProperties } from '../../../../common/threshold_rule/types'; import { createSeries, derivedIndexPattern, options, resp, - source, timestamps, } from '../../../utils/metrics_explorer'; @@ -54,20 +52,12 @@ const renderUseMetricsExplorerDataHook = () => { return renderHook( (props: { options: MetricsExplorerOptions; - source: MetricsSourceConfigurationProperties | undefined; derivedIndexPattern: DataViewBase; timestamps: MetricsExplorerTimestampsRT; - }) => - useMetricsExplorerData( - props.options, - props.source, - props.derivedIndexPattern, - props.timestamps - ), + }) => useMetricsExplorerData(props.options, props.derivedIndexPattern, props.timestamps), { initialProps: { options, - source, derivedIndexPattern, timestamps, }, @@ -163,7 +153,6 @@ describe('useMetricsExplorerData Hook', () => { aggregation: 'count', metrics: [{ aggregation: 'count' }], }, - source, derivedIndexPattern, timestamps, }); @@ -187,7 +176,6 @@ describe('useMetricsExplorerData Hook', () => { mockedFetch.mockResolvedValue(resp as any); rerender({ options, - source, derivedIndexPattern, timestamps: { fromTimestamp: 1678378092225, toTimestamp: 1678381693477, interval: '>=10s' }, }); diff --git a/x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_data.ts b/x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_data.ts similarity index 90% rename from x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_data.ts rename to x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_data.ts index 206e8fe3818f1..eeb4d52d70c36 100644 --- a/x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_data.ts +++ b/x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_data.ts @@ -9,7 +9,6 @@ import { DataViewBase } from '@kbn/es-query'; import { useInfiniteQuery } from '@tanstack/react-query'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { MetricsSourceConfigurationProperties } from '../../../../common/threshold_rule/types'; import { MetricsExplorerResponse, metricsExplorerResponseRT, @@ -24,7 +23,6 @@ import { decodeOrThrow } from '../helpers/runtime_types'; export function useMetricsExplorerData( options: MetricsExplorerOptions, - source: MetricsSourceConfigurationProperties | undefined, derivedIndexPattern: DataViewBase, { fromTimestamp, toTimestamp, interval }: MetricsExplorerTimestampsRT, enabled = true @@ -35,7 +33,7 @@ export function useMetricsExplorerData( MetricsExplorerResponse, Error >({ - queryKey: ['metricExplorer', options, fromTimestamp, toTimestamp], + queryKey: ['metricExplorer', options, fromTimestamp, toTimestamp, derivedIndexPattern.title], queryFn: async ({ signal, pageParam = { afterKey: null } }) => { if (!fromTimestamp || !toTimestamp) { throw new Error('Unable to parse timerange'); @@ -43,8 +41,8 @@ export function useMetricsExplorerData( if (!http) { throw new Error('HTTP service is unavailable'); } - if (!source) { - throw new Error('Source is unavailable'); + if (!derivedIndexPattern.title) { + throw new Error('Data view is unavailable'); } const { afterKey } = pageParam; @@ -57,7 +55,7 @@ export function useMetricsExplorerData( groupBy: options.groupBy, afterKey, limit: options.limit, - indexPattern: source.metricAlias, + indexPattern: derivedIndexPattern.title, filterQuery: (options.filterQuery && convertKueryToElasticSearchQuery(options.filterQuery, derivedIndexPattern)) || @@ -74,7 +72,7 @@ export function useMetricsExplorerData( return decodeOrThrow(metricsExplorerResponseRT)(response); }, getNextPageParam: (lastPage) => lastPage.pageInfo, - enabled: enabled && !!fromTimestamp && !!toTimestamp && !!http && !!source, + enabled: enabled && !!fromTimestamp && !!toTimestamp && !!http && !!derivedIndexPattern.title, refetchOnWindowFocus: false, }); diff --git a/x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_options.test.tsx b/x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_options.test.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_options.test.tsx rename to x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_options.test.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_options.ts b/x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_options.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_options.ts rename to x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_options.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/hooks/use_tracked_promise.ts b/x-pack/plugins/observability/public/components/threshold/hooks/use_tracked_promise.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/hooks/use_tracked_promise.ts rename to x-pack/plugins/observability/public/components/threshold/hooks/use_tracked_promise.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/i18n_strings.ts b/x-pack/plugins/observability/public/components/threshold/i18n_strings.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/i18n_strings.ts rename to x-pack/plugins/observability/public/components/threshold/i18n_strings.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/lib/generate_unique_key.test.ts b/x-pack/plugins/observability/public/components/threshold/lib/generate_unique_key.test.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/lib/generate_unique_key.test.ts rename to x-pack/plugins/observability/public/components/threshold/lib/generate_unique_key.test.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/lib/generate_unique_key.ts b/x-pack/plugins/observability/public/components/threshold/lib/generate_unique_key.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/lib/generate_unique_key.ts rename to x-pack/plugins/observability/public/components/threshold/lib/generate_unique_key.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/lib/transform_metrics_explorer_data.ts b/x-pack/plugins/observability/public/components/threshold/lib/transform_metrics_explorer_data.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/lib/transform_metrics_explorer_data.ts rename to x-pack/plugins/observability/public/components/threshold/lib/transform_metrics_explorer_data.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/mocks/metric_threshold_rule.ts b/x-pack/plugins/observability/public/components/threshold/mocks/metric_threshold_rule.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/mocks/metric_threshold_rule.ts rename to x-pack/plugins/observability/public/components/threshold/mocks/metric_threshold_rule.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/rule_data_formatters.ts b/x-pack/plugins/observability/public/components/threshold/rule_data_formatters.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/rule_data_formatters.ts rename to x-pack/plugins/observability/public/components/threshold/rule_data_formatters.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/types.ts b/x-pack/plugins/observability/public/components/threshold/types.ts similarity index 95% rename from x-pack/plugins/observability/public/pages/threshold/types.ts rename to x-pack/plugins/observability/public/components/threshold/types.ts index 6317b87ba2767..8aa3eea9ccbf3 100644 --- a/x-pack/plugins/observability/public/pages/threshold/types.ts +++ b/x-pack/plugins/observability/public/components/threshold/types.ts @@ -7,8 +7,8 @@ import * as rt from 'io-ts'; import { CasesUiStart } from '@kbn/cases-plugin/public'; import { ChartsPluginStart } from '@kbn/charts-plugin/public'; -import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { DataPublicPluginStart, SerializedSearchSourceFields } from '@kbn/data-plugin/public'; +import { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { DiscoverStart } from '@kbn/discover-plugin/public'; import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; @@ -38,6 +38,7 @@ import { ObservabilityPublicStart } from '../../plugin'; import { MetricsExplorerOptions } from './hooks/use_metrics_explorer_options'; export interface AlertContextMeta { + adHocDataViewList: DataView[]; currentOptions?: Partial; series?: MetricsExplorerSeries; } @@ -94,6 +95,7 @@ export interface AlertParams { filterQueryText?: string; alertOnNoData?: boolean; alertOnGroupDisappear?: boolean; + searchConfiguration: SerializedSearchSourceFields; shouldDropPartialBuckets?: boolean; } diff --git a/x-pack/plugins/observability/public/pages/threshold/helpers/source.tsx b/x-pack/plugins/observability/public/pages/threshold/helpers/source.tsx deleted file mode 100644 index bf1ad0134ea8f..0000000000000 --- a/x-pack/plugins/observability/public/pages/threshold/helpers/source.tsx +++ /dev/null @@ -1,142 +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 createContainer from 'constate'; -import React, { useEffect, useState } from 'react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { IHttpFetchError } from '@kbn/core-http-browser'; -import { - MetricsSourceConfiguration, - MetricsSourceConfigurationResponse, - PartialMetricsSourceConfigurationProperties, -} from '../../../../common/threshold_rule/types'; -import { MissingHttpClientException } from './source_errors'; -import { useTrackedPromise } from '../hooks/use_tracked_promise'; -import { useSourceNotifier } from './notifications'; - -export const pickIndexPattern = ( - source: MetricsSourceConfiguration | undefined, - type: 'metrics' -) => { - if (!source) { - return 'unknown-index'; - } - if (type === 'metrics') { - return source.configuration.metricAlias; - } - return `${source.configuration.metricAlias}`; -}; - -export const useSource = ({ sourceId }: { sourceId: string }) => { - const { services } = useKibana(); - - const notify = useSourceNotifier(); - - const fetchService = services.http; - const API_URL = `/api/metrics/source/${sourceId}`; - - const [source, setSource] = useState(undefined); - - const [loadSourceRequest, loadSource] = useTrackedPromise( - { - cancelPreviousOn: 'resolution', - createPromise: () => { - if (!fetchService) { - throw new MissingHttpClientException(); - } - - return fetchService.fetch(API_URL, { method: 'GET' }); - }, - onResolve: (response) => { - if (response) { - setSource(response.source); - } - }, - }, - [fetchService, sourceId] - ); - - const [createSourceConfigurationRequest, createSourceConfiguration] = useTrackedPromise( - { - createPromise: async (sourceProperties: PartialMetricsSourceConfigurationProperties) => { - if (!fetchService) { - throw new MissingHttpClientException(); - } - - return await fetchService.patch(API_URL, { - method: 'PATCH', - body: JSON.stringify(sourceProperties), - }); - }, - onResolve: (response) => { - if (response) { - notify.updateSuccess(); - setSource(response.source); - } - }, - onReject: (error) => { - notify.updateFailure((error as IHttpFetchError<{ message: string }>).body?.message); - }, - }, - [fetchService, sourceId] - ); - - useEffect(() => { - loadSource(); - }, [loadSource, sourceId]); - - const createDerivedIndexPattern = () => { - return { - fields: source?.status ? source.status.indexFields : [], - title: pickIndexPattern(source, 'metrics'), - }; - }; - - const hasFailedLoadingSource = loadSourceRequest.state === 'rejected'; - const isUninitialized = loadSourceRequest.state === 'uninitialized'; - const isLoadingSource = loadSourceRequest.state === 'pending'; - const isLoading = isLoadingSource || createSourceConfigurationRequest.state === 'pending'; - - const sourceExists = source ? !!source.version : undefined; - - const metricIndicesExist = Boolean(source?.status?.metricIndicesExist); - - const version = source?.version; - - return { - createSourceConfiguration, - createDerivedIndexPattern, - isLoading, - isLoadingSource, - isUninitialized, - hasFailedLoadingSource, - loadSource, - loadSourceRequest, - loadSourceFailureMessage: hasFailedLoadingSource ? `${loadSourceRequest.value}` : undefined, - metricIndicesExist, - source, - sourceExists, - sourceId, - updateSourceConfiguration: createSourceConfiguration, - version, - }; -}; - -export const [SourceProvider, useSourceContext] = createContainer(useSource); - -export const withSourceProvider = - (Component: React.FunctionComponent) => - (sourceId = 'default') => { - // eslint-disable-next-line react/function-component-definition - return function ComponentWithSourceProvider(props: ComponentProps) { - return ( - - - - ); - }; - }; diff --git a/x-pack/plugins/observability/public/plugin.mock.tsx b/x-pack/plugins/observability/public/plugin.mock.tsx index 1a42c2ed98aa8..aa847fd198cf4 100644 --- a/x-pack/plugins/observability/public/plugin.mock.tsx +++ b/x-pack/plugins/observability/public/plugin.mock.tsx @@ -71,6 +71,33 @@ const data = { timefilter: jest.fn(), }, }, + search: { + searchSource: jest.fn(), + }, + }; + }, +}; + +const dataViewEditor = { + createStart() { + return { + userPermissions: { + editDataView: jest.fn(), + }, + }; + }, +}; + +const dataViews = { + createStart() { + return { + getIds: jest.fn().mockImplementation(() => []), + get: jest.fn(), + create: jest.fn().mockImplementation(() => ({ + fields: { + getByName: jest.fn(), + }, + })), }; }, }; @@ -81,6 +108,8 @@ export const observabilityPublicPluginsStartMock = { cases: mockCasesContract(), triggersActionsUi: triggersActionsUiStartMock.createStart(), data: data.createStart(), + dataViews: dataViews.createStart(), + dataViewEditor: dataViewEditor.createStart(), lens: null, discover: null, }; diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index fd6300eff8caa..d87a95e0f7284 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import { BehaviorSubject, from } from 'rxjs'; import { map } from 'rxjs/operators'; +import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; import { AppDeepLink, @@ -103,6 +104,7 @@ export interface ObservabilityPublicPluginsStart { charts: ChartsPluginStart; data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; + dataViewEditor: DataViewEditorStart; discover: DiscoverStart; embeddable: EmbeddableStart; exploratoryView: ExploratoryViewPublicStart; diff --git a/x-pack/plugins/observability/public/rules/register_observability_rule_types.ts b/x-pack/plugins/observability/public/rules/register_observability_rule_types.ts index 552473b36ded4..e6e5412cc046e 100644 --- a/x-pack/plugins/observability/public/rules/register_observability_rule_types.ts +++ b/x-pack/plugins/observability/public/rules/register_observability_rule_types.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import { lazy } from 'react'; import { i18n } from '@kbn/i18n'; import { ALERT_REASON } from '@kbn/rule-data-utils'; @@ -16,8 +17,8 @@ import { SLO_BURN_RATE_RULE_TYPE_ID, } from '../../common/constants'; import { validateBurnRateRule } from '../components/burn_rate_rule_editor/validation'; -import { validateMetricThreshold } from '../pages/threshold/components/validation'; -import { formatReason } from '../pages/threshold/rule_data_formatters'; +import { validateMetricThreshold } from '../components/threshold/components/validation'; +import { formatReason } from '../components/threshold/rule_data_formatters'; const sloBurnRateDefaultActionMessage = i18n.translate( 'xpack.observability.slo.rules.burnRate.defaultActionMessage', @@ -91,7 +92,7 @@ export const registerObservabilityRuleTypes = ( documentationUrl(docLinks) { return `${docLinks.links.observability.threshold}`; }, - ruleParamsExpression: lazy(() => import('../pages/threshold/components/expression')), + ruleParamsExpression: lazy(() => import('../components/threshold/components/expression')), validate: validateMetricThreshold, defaultActionMessage: i18n.translate( 'xpack.observability.threshold.rule.alerting.threshold.defaultActionMessage', @@ -106,7 +107,7 @@ export const registerObservabilityRuleTypes = ( requiresAppContext: false, format: formatReason, alertDetailsAppSection: lazy( - () => import('../pages/threshold/components/alert_details_app_section') + () => import('../components/threshold/components/alert_details_app_section') ), }); } diff --git a/x-pack/plugins/observability/public/utils/metrics_explorer.ts b/x-pack/plugins/observability/public/utils/metrics_explorer.ts index ba8168a6f939e..fbbeb8a4f1937 100644 --- a/x-pack/plugins/observability/public/utils/metrics_explorer.ts +++ b/x-pack/plugins/observability/public/utils/metrics_explorer.ts @@ -16,7 +16,7 @@ import { MetricsExplorerTimeOptions, MetricsExplorerTimestampsRT, MetricsExplorerYAxisMode, -} from '../pages/threshold/hooks/use_metrics_explorer_options'; +} from '../components/threshold/hooks/use_metrics_explorer_options'; export const options: MetricsExplorerOptions = { limit: 3, diff --git a/x-pack/plugins/observability/server/lib/rules/threshold/register_threshold_rule_type.ts b/x-pack/plugins/observability/server/lib/rules/threshold/register_threshold_rule_type.ts index 420bf33e77802..543492e6719f2 100644 --- a/x-pack/plugins/observability/server/lib/rules/threshold/register_threshold_rule_type.ts +++ b/x-pack/plugins/observability/server/lib/rules/threshold/register_threshold_rule_type.ts @@ -6,6 +6,7 @@ */ import { schema } from '@kbn/config-schema'; +import { extractReferences, injectReferences } from '@kbn/data-plugin/common'; import { i18n } from '@kbn/i18n'; import { IRuleTypeAlerts } from '@kbn/alerting-plugin/server'; import { IBasePath, Logger } from '@kbn/core/server'; @@ -16,10 +17,11 @@ import { IRuleDataClient, } from '@kbn/rule-registry-plugin/server'; import { LicenseType } from '@kbn/licensing-plugin/server'; -import { THRESHOLD_RULE_REGISTRATION_CONTEXT } from '../../../common/constants'; +import { EsQueryRuleParamsExtractedParams } from '@kbn/stack-alerts-plugin/server/rule_types/es_query/rule_type_params'; import { observabilityFeatureId } from '../../../../common'; import { Comparator } from '../../../../common/threshold_rule/types'; import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '../../../../common/constants'; +import { THRESHOLD_RULE_REGISTRATION_CONTEXT } from '../../../common/constants'; import { alertDetailUrlActionVariableDescription, @@ -148,7 +150,6 @@ export function thresholdRuleType( validate: validateIsStringElasticsearchJSONFilter, }) ), - sourceId: schema.string(), alertOnNoData: schema.maybe(schema.boolean()), alertOnGroupDisappear: schema.maybe(schema.boolean()), }, @@ -203,6 +204,21 @@ export function thresholdRuleType( }, ], }, + useSavedObjectReferences: { + // TODO revisit types https://github.com/elastic/kibana/issues/159714 + extractReferences: (params: any) => { + const [searchConfiguration, references] = extractReferences(params.searchConfiguration); + const newParams = { ...params, searchConfiguration } as EsQueryRuleParamsExtractedParams; + + return { params: newParams, references }; + }, + injectReferences: (params: any, references: any) => { + return { + ...params, + searchConfiguration: injectReferences(params.searchConfiguration, references), + }; + }, + }, producer: observabilityFeatureId, getSummarizedAlerts: getSummarizedAlerts(), alerts: MetricsRulesTypeAlertDefinition, diff --git a/x-pack/plugins/observability/server/lib/rules/threshold/test_mocks.ts b/x-pack/plugins/observability/server/lib/rules/threshold/test_mocks.ts deleted file mode 100644 index 3d3c7a17cd1dd..0000000000000 --- a/x-pack/plugins/observability/server/lib/rules/threshold/test_mocks.ts +++ /dev/null @@ -1,237 +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. - */ - -const bucketsA = (from: number) => [ - { - doc_count: null, - aggregatedValue: { value: null, values: [{ key: 95.0, value: null }] }, - from_as_string: new Date(from).toISOString(), - }, - { - doc_count: 2, - aggregatedValue: { value: 0.5, values: [{ key: 95.0, value: 0.5 }] }, - from_as_string: new Date(from + 60000).toISOString(), - }, - { - doc_count: 2, - aggregatedValue: { value: 0.5, values: [{ key: 95.0, value: 0.5 }] }, - from_as_string: new Date(from + 120000).toISOString(), - }, - { - doc_count: 2, - aggregatedValue: { value: 0.5, values: [{ key: 95.0, value: 0.5 }] }, - from_as_string: new Date(from + 180000).toISOString(), - }, - { - doc_count: 3, - aggregatedValue: { value: 1.0, values: [{ key: 95.0, value: 1.0 }] }, - from_as_string: new Date(from + 240000).toISOString(), - }, - { - doc_count: 1, - aggregatedValue: { value: 1.0, values: [{ key: 95.0, value: 1.0 }] }, - from_as_string: new Date(from + 300000).toISOString(), - }, -]; - -const bucketsB = (from: number) => [ - { - doc_count: 0, - aggregatedValue: { value: null, values: [{ key: 99.0, value: null }] }, - from_as_string: new Date(from).toISOString(), - }, - { - doc_count: 4, - aggregatedValue: { value: 2.5, values: [{ key: 99.0, value: 2.5 }] }, - from_as_string: new Date(from + 60000).toISOString(), - }, - { - doc_count: 4, - aggregatedValue: { value: 2.5, values: [{ key: 99.0, value: 2.5 }] }, - from_as_string: new Date(from + 120000).toISOString(), - }, - { - doc_count: 4, - aggregatedValue: { value: 2.5, values: [{ key: 99.0, value: 2.5 }] }, - from_as_string: new Date(from + 180000).toISOString(), - }, - { - doc_count: 5, - aggregatedValue: { value: 3.5, values: [{ key: 99.0, value: 3.5 }] }, - from_as_string: new Date(from + 240000).toISOString(), - }, - { - doc_count: 1, - aggregatedValue: { value: 3, values: [{ key: 99.0, value: 3 }] }, - from_as_string: new Date(from + 300000).toISOString(), - }, -]; - -const bucketsC = (from: number) => [ - { - doc_count: 0, - aggregatedValue: { value: null }, - from_as_string: new Date(from).toISOString(), - }, - { - doc_count: 2, - aggregatedValue: { value: 0.5 }, - from_as_string: new Date(from + 60000).toISOString(), - }, - { - doc_count: 2, - aggregatedValue: { value: 0.5 }, - from_as_string: new Date(from + 120000).toISOString(), - }, - { - doc_count: 2, - aggregatedValue: { value: 0.5 }, - from_as_string: new Date(from + 180000).toISOString(), - }, - { - doc_count: 3, - aggregatedValue: { value: 16 }, - from_as_string: new Date(from + 240000).toISOString(), - }, - { - doc_count: 1, - aggregatedValue: { value: 3 }, - from_as_string: new Date(from + 300000).toISOString(), - }, -]; - -export const basicMetricResponse = () => ({ - hits: { - total: { - value: 1, - }, - }, - aggregations: { - aggregatedValue: { value: 1.0, values: [{ key: 95.0, value: 1.0 }] }, - }, -}); - -export const alternateMetricResponse = () => ({ - hits: { - total: { - value: 1, - }, - }, - aggregations: { - aggregatedValue: { value: 3, values: [{ key: 99.0, value: 3 }] }, - }, -}); - -export const emptyMetricResponse = { - aggregations: { - aggregatedIntervals: { - buckets: [], - }, - }, -}; - -export const emptyRateResponse = (from: number) => ({ - aggregations: { - aggregatedIntervals: { - buckets: [ - { - doc_count: 2, - aggregatedValueMax: { value: null }, - from_as_string: new Date(from).toISOString(), - }, - ], - }, - }, -}); - -export const basicCompositeResponse = (from: number) => ({ - aggregations: { - groupings: { - after_key: { groupBy0: 'foo' }, - buckets: [ - { - key: { - groupBy0: 'a', - }, - aggregatedIntervals: { - buckets: bucketsA(from), - }, - doc_count: 1, - }, - { - key: { - groupBy0: 'b', - }, - aggregatedIntervals: { - buckets: bucketsB(from), - }, - doc_count: 1, - }, - ], - }, - }, - hits: { - total: { - value: 2, - }, - }, -}); - -export const alternateCompositeResponse = (from: number) => ({ - aggregations: { - groupings: { - after_key: { groupBy0: 'foo' }, - buckets: [ - { - key: { - groupBy0: 'a', - }, - aggregatedIntervals: { - buckets: bucketsB(from), - }, - doc_count: 1, - }, - { - key: { - groupBy0: 'b', - }, - aggregatedIntervals: { - buckets: bucketsA(from), - }, - doc_count: 1, - }, - { - key: { - groupBy0: 'c', - }, - aggregatedIntervals: { - buckets: bucketsC(from), - }, - doc_count: 1, - }, - ], - }, - }, - hits: { - total: { - value: 3, - }, - }, -}); - -export const compositeEndResponse = { - aggregations: {}, - hits: { total: { value: 0 } }, -}; - -export const changedSourceIdResponse = (from: number) => ({ - aggregations: { - aggregatedIntervals: { - buckets: bucketsC(from), - }, - }, -}); diff --git a/x-pack/plugins/observability/server/lib/rules/threshold/threshold_executor.ts b/x-pack/plugins/observability/server/lib/rules/threshold/threshold_executor.ts index 25e7ef8fb7967..5e11aca243f14 100644 --- a/x-pack/plugins/observability/server/lib/rules/threshold/threshold_executor.ts +++ b/x-pack/plugins/observability/server/lib/rules/threshold/threshold_executor.ts @@ -120,7 +120,7 @@ export const createMetricThresholdExecutor = ({ }); // TODO: check if we need to use "savedObjectsClient"=> https://github.com/elastic/kibana/issues/159340 - const { alertWithLifecycle, getAlertUuid, getAlertByAlertUuid, dataViews } = services; + const { alertWithLifecycle, getAlertUuid, getAlertByAlertUuid, searchSourceClient } = services; const alertFactory: MetricThresholdAlertFactory = ( id, @@ -138,9 +138,8 @@ export const createMetricThresholdExecutor = ({ ...flattenAdditionalContext(additionalContext), }, }); - // TODO: check if we need to use "sourceId" + const { alertOnNoData, alertOnGroupDisappear: _alertOnGroupDisappear } = params as { - sourceId?: string; alertOnNoData: boolean; alertOnGroupDisappear: boolean | undefined; }; @@ -188,9 +187,9 @@ export const createMetricThresholdExecutor = ({ alertOnGroupDisappear && filterQueryIsSame && groupByIsSame && state.missingGroups ? state.missingGroups : []; - // TODO: check the DATA VIEW - const defaultDataView = await dataViews.getDefaultDataView(); - const dataView = defaultDataView?.getIndexPattern(); + + const initialSearchSource = await searchSourceClient.create(params.searchConfiguration!); + const dataView = initialSearchSource.getField('index')!.getIndexPattern(); if (!dataView) { throw new Error('No matched data view'); } diff --git a/x-pack/plugins/observability/server/lib/rules/threshold/types.ts b/x-pack/plugins/observability/server/lib/rules/threshold/types.ts index 0f3c10611076e..d98a59bd50b9b 100644 --- a/x-pack/plugins/observability/server/lib/rules/threshold/types.ts +++ b/x-pack/plugins/observability/server/lib/rules/threshold/types.ts @@ -37,7 +37,6 @@ export interface MetricAnomalyParams { nodeType: rt.TypeOf; metric: rt.TypeOf; alertInterval?: string; - sourceId?: string; spaceId?: string; threshold: Exclude; influencerFilter: rt.TypeOf | undefined; @@ -48,7 +47,6 @@ export interface MetricAnomalyParams { interface BaseMetricExpressionParams { timeSize: number; timeUnit: TimeUnitChar; - sourceId?: string; threshold: number[]; comparator: Comparator; warningComparator?: Comparator; diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index 77f668338ba35..09e809d69542b 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -77,7 +77,9 @@ "@kbn/safer-lodash-set", "@kbn/core-http-server", "@kbn/cloud-chat-plugin", - "@kbn/cloud-plugin" + "@kbn/cloud-plugin", + "@kbn/stack-alerts-plugin", + "@kbn/data-view-editor-plugin" ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/stack_alerts/public/index.ts b/x-pack/plugins/stack_alerts/public/index.ts index d043883d6e248..da63456eca347 100644 --- a/x-pack/plugins/stack_alerts/public/index.ts +++ b/x-pack/plugins/stack_alerts/public/index.ts @@ -7,4 +7,6 @@ import { StackAlertsPublicPlugin } from './plugin'; +export { DataViewSelectPopover } from './rule_types/components/data_view_select_popover'; + export const plugin = () => new StackAlertsPublicPlugin(); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.test.tsx index 78969e3e5dd21..b6729d9ab6395 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.test.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { DataViewSelectPopover, DataViewSelectPopoverProps } from './data_view_select_popover'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import type { DataView } from '@kbn/data-views-plugin/public'; import { indexPatternEditorPluginMock as dataViewEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks'; @@ -24,12 +23,6 @@ const selectedDataView = { getName: () => 'kibana_sample_data_logs', } as unknown as DataView; -const props: DataViewSelectPopoverProps = { - onSelectDataView: () => {}, - onChangeMetaData: () => {}, - dataView: selectedDataView, -}; - const dataViewIds = ['mock-data-logs-id', 'mock-ecommerce-id', 'mock-test-id', 'mock-ad-hoc-id']; const dataViewOptions = [ @@ -80,15 +73,15 @@ const mount = () => { Promise.resolve(dataViewOptions.find((current) => current.id === id)) ); const dataViewEditorMock = dataViewEditorPluginMock.createStartContract(); + const props: DataViewSelectPopoverProps = { + dependencies: { dataViews: dataViewsMock, dataViewEditor: dataViewEditorMock }, + onSelectDataView: () => {}, + onChangeMetaData: () => {}, + dataView: selectedDataView, + }; return { - wrapper: mountWithIntl( - - - - ), + wrapper: mountWithIntl(), dataViewsMock, }; }; diff --git a/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.tsx b/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.tsx index ac71501dd5c34..2bf75dcbd9c08 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.tsx @@ -20,13 +20,17 @@ import { EuiText, useEuiPaddingCSS, } from '@elastic/eui'; -import type { DataView } from '@kbn/data-views-plugin/public'; +import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; +import type { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { DataViewSelector } from '@kbn/unified-search-plugin/public'; import type { DataViewListItemEnhanced } from '@kbn/unified-search-plugin/public/dataview_picker/dataview_list'; -import { useTriggerUiActionServices } from '../es_query/util'; import { EsQueryRuleMetaData } from '../es_query/types'; export interface DataViewSelectPopoverProps { + dependencies: { + dataViews: DataViewsPublicPluginStart; + dataViewEditor: DataViewEditorStart; + }; dataView?: DataView; metadata?: EsQueryRuleMetaData; onSelectDataView: (selectedDataView: DataView) => void; @@ -43,12 +47,12 @@ const toDataViewListItem = (dataView: DataView): DataViewListItemEnhanced => { }; export const DataViewSelectPopover: React.FunctionComponent = ({ + dependencies: { dataViews, dataViewEditor }, metadata = { adHocDataViewList: [], isManagementPage: true }, dataView, onSelectDataView, onChangeMetaData, }) => { - const { dataViews, dataViewEditor } = useTriggerUiActionServices(); const [dataViewItems, setDataViewsItems] = useState([]); const [dataViewPopoverOpen, setDataViewPopoverOpen] = useState(false); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx index f2a6ca3a84cee..1cc45801ffe65 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx @@ -78,6 +78,7 @@ const isSearchSourceParam = (action: LocalStateAction): action is SearchSourcePa export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProps) => { const services = useTriggerUiActionServices(); const unifiedSearch = services.unifiedSearch; + const { dataViews, dataViewEditor } = useTriggerUiActionServices(); const { searchSource, errors, initialSavedQuery, setParam, ruleParams } = props; const [savedQuery, setSavedQuery] = useState(); @@ -117,7 +118,7 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp ); const { index: dataView, query, filter: filters } = ruleConfiguration; - const dataViews = useMemo(() => (dataView ? [dataView] : []), [dataView]); + const indexPatterns = useMemo(() => (dataView ? [dataView] : []), [dataView]); const [esFields, setEsFields] = useState( dataView ? convertFieldSpecToFieldOption(dataView.fields.map((field) => field.toSpec())) : [] @@ -296,6 +297,7 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp Date: Wed, 21 Jun 2023 05:21:16 -0700 Subject: [PATCH 14/31] [Security Solution] [Fix] [Performance] Alert Page Controls should query only from Alert Index. (#157286) ## Summary Handles : #157217 |Before|After| |---|---| | ) : ( = ({ [ topLevelFilters, arePageFiltersEnabled, - dataViewId, statusFilter, onFilterGroupChangedCallback, pageFiltersUpdateHandler, From de3f8fca009f6c22bcaf3ab20d2beea8ffd7e549 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Wed, 21 Jun 2023 15:00:25 +0200 Subject: [PATCH 15/31] [Cases] Limit perPage param in findComments API (#160042) ## Summary This PR limits `perPage` param to 100 in `findComments` API. ### Checklist - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: lcawl Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../cases/case-apis-passthru.asciidoc | 2 +- .../plugins/cases/common/constants/index.ts | 1 + .../plugins/cases/docs/openapi/bundled.json | 10 +++++++++- .../plugins/cases/docs/openapi/bundled.yaml | 9 ++++++++- ...id}@api@cases@{caseid}@comments@_find.yaml | 9 ++++++++- .../server/client/attachments/get.test.ts | 10 +++++++++- .../client/attachments/validators.test.ts | 19 ++++++++++++++++--- .../server/client/attachments/validators.ts | 10 ++++++++-- .../tests/common/cases/delete_cases.ts | 10 +++++----- .../tests/common/comments/find_comments.ts | 2 +- 10 files changed, 66 insertions(+), 16 deletions(-) diff --git a/docs/api-generated/cases/case-apis-passthru.asciidoc b/docs/api-generated/cases/case-apis-passthru.asciidoc index d0e7a67c50660..f007882af237c 100644 --- a/docs/api-generated/cases/case-apis-passthru.asciidoc +++ b/docs/api-generated/cases/case-apis-passthru.asciidoc @@ -556,7 +556,7 @@ Any modifications made to this file will be overwritten.
Query Parameter — The page number to return. default: 1
perPage (optional)
-
Query Parameter — The number of items to return. default: 20
sortOrder (optional)
+
Query Parameter — The number of items to return. Limited to 100 items. default: 20
sortOrder (optional)
Query Parameter — Determines the sort order. default: desc
diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index 33473a9c80bfd..1a49de004e73f 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -104,6 +104,7 @@ export const MAX_DOCS_PER_PAGE = 10000 as const; export const MAX_BULK_GET_ATTACHMENTS = MAX_DOCS_PER_PAGE; export const MAX_CONCURRENT_SEARCHES = 10 as const; export const MAX_BULK_GET_CASES = 1000 as const; +export const MAX_COMMENTS_PER_PAGE = 100 as const; export const MAX_CATEGORY_FILTER_LENGTH = 100 as const; /** diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json index 92dcfe5779306..78ba5444f40aa 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.json +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -1852,7 +1852,15 @@ "$ref": "#/components/parameters/page_index" }, { - "$ref": "#/components/parameters/page_size" + "name": "perPage", + "in": "query", + "description": "The number of items to return. Limited to 100 items.", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } }, { "$ref": "#/components/parameters/sort_order" diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml index 3d53916c89df8..405cf4fb689f0 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.yaml +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -1156,7 +1156,14 @@ paths: parameters: - $ref: '#/components/parameters/case_id' - $ref: '#/components/parameters/page_index' - - $ref: '#/components/parameters/page_size' + - name: perPage + in: query + description: The number of items to return. Limited to 100 items. + required: false + schema: + type: integer + default: 20 + maximum: 100 - $ref: '#/components/parameters/sort_order' - $ref: '#/components/parameters/space_id' responses: diff --git a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@_find.yaml b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@_find.yaml index b1dd32a659515..bb43f4dcc0b26 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@_find.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@_find.yaml @@ -10,7 +10,14 @@ get: parameters: - $ref: '../components/parameters/case_id.yaml' - $ref: '../components/parameters/page_index.yaml' - - $ref: '../components/parameters/page_size.yaml' + - name: perPage + in: query + description: The number of items to return. Limited to 100 items. + required: false + schema: + type: integer + default: 20 + maximum: 100 - $ref: '../components/parameters/sort_order.yaml' - $ref: '../components/parameters/space_id.yaml' responses: diff --git a/x-pack/plugins/cases/server/client/attachments/get.test.ts b/x-pack/plugins/cases/server/client/attachments/get.test.ts index 250cfa9f0b252..c2c3423b2388b 100644 --- a/x-pack/plugins/cases/server/client/attachments/get.test.ts +++ b/x-pack/plugins/cases/server/client/attachments/get.test.ts @@ -18,12 +18,20 @@ describe('get', () => { it('Invalid total items results in error', async () => { await expect(() => - findComment({ caseID: 'mock-id', findQueryParams: { page: 2, perPage: 9001 } }, clientArgs) + findComment({ caseID: 'mock-id', findQueryParams: { page: 209, perPage: 100 } }, clientArgs) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Failed to find comments case id: mock-id: Error: The number of documents is too high. Paginating through more than 10,000 documents is not possible."` ); }); + it('Invalid perPage items results in error', async () => { + await expect(() => + findComment({ caseID: 'mock-id', findQueryParams: { page: 2, perPage: 9001 } }, clientArgs) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to find comments case id: mock-id: Error: The provided perPage value was too high. The maximum allowed perPage value is 100."` + ); + }); + it('throws with excess fields', async () => { await expect( findComment( diff --git a/x-pack/plugins/cases/server/client/attachments/validators.test.ts b/x-pack/plugins/cases/server/client/attachments/validators.test.ts index 4626fd13fba35..ce6c43a665a10 100644 --- a/x-pack/plugins/cases/server/client/attachments/validators.test.ts +++ b/x-pack/plugins/cases/server/client/attachments/validators.test.ts @@ -6,10 +6,13 @@ */ import { validateFindCommentsPagination } from './validators'; +import { MAX_COMMENTS_PER_PAGE } from '../../../common/constants'; const ERROR_MSG = 'The number of documents is too high. Paginating through more than 10,000 documents is not possible.'; +const ERROR_MSG_PER_PAGE = `The provided perPage value was too high. The maximum allowed perPage value is ${MAX_COMMENTS_PER_PAGE}.`; + describe('validators', () => { describe('validateFindCommentsPagination', () => { it('does not throw if only page is undefined', () => { @@ -20,20 +23,30 @@ describe('validators', () => { expect(() => validateFindCommentsPagination({ page: 100 })).not.toThrowError(); }); + it('does not throw if page and perPage are defined and valid', () => { + expect(() => validateFindCommentsPagination({ page: 2, perPage: 100 })).not.toThrowError(); + }); + it('returns if page and perPage are undefined', () => { expect(() => validateFindCommentsPagination({})).not.toThrowError(); }); + it('returns if perPage < 0', () => { + expect(() => validateFindCommentsPagination({ perPage: -1 })).not.toThrowError(); + }); + it('throws if page > 10k', () => { expect(() => validateFindCommentsPagination({ page: 10001 })).toThrow(ERROR_MSG); }); - it('throws if perPage > 10k', () => { - expect(() => validateFindCommentsPagination({ perPage: 10001 })).toThrowError(ERROR_MSG); + it('throws if perPage > 100', () => { + expect(() => + validateFindCommentsPagination({ perPage: MAX_COMMENTS_PER_PAGE + 1 }) + ).toThrowError(ERROR_MSG_PER_PAGE); }); it('throws if page * perPage > 10k', () => { - expect(() => validateFindCommentsPagination({ page: 10, perPage: 1001 })).toThrow(ERROR_MSG); + expect(() => validateFindCommentsPagination({ page: 101, perPage: 100 })).toThrow(ERROR_MSG); }); }); }); diff --git a/x-pack/plugins/cases/server/client/attachments/validators.ts b/x-pack/plugins/cases/server/client/attachments/validators.ts index 770d9d8e88c0f..ea38fa71e702a 100644 --- a/x-pack/plugins/cases/server/client/attachments/validators.ts +++ b/x-pack/plugins/cases/server/client/attachments/validators.ts @@ -6,7 +6,7 @@ */ import Boom from '@hapi/boom'; -import { MAX_DOCS_PER_PAGE } from '../../../common/constants'; +import { MAX_DOCS_PER_PAGE, MAX_COMMENTS_PER_PAGE } from '../../../common/constants'; import { isCommentRequestTypeExternalReference, isCommentRequestTypePersistableState, @@ -51,7 +51,13 @@ export const validateFindCommentsPagination = (params?: FindCommentsQueryParams) const pageAsNumber = params.page ?? 0; const perPageAsNumber = params.perPage ?? 0; - if (Math.max(pageAsNumber, perPageAsNumber, pageAsNumber * perPageAsNumber) > MAX_DOCS_PER_PAGE) { + if (perPageAsNumber > MAX_COMMENTS_PER_PAGE) { + throw Boom.badRequest( + `The provided perPage value was too high. The maximum allowed perPage value is ${MAX_COMMENTS_PER_PAGE}.` + ); + } + + if (Math.max(pageAsNumber, pageAsNumber * perPageAsNumber) > MAX_DOCS_PER_PAGE) { throw Boom.badRequest( 'The number of documents is too high. Paginating through more than 10,000 documents is not possible.' ); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts index 267f5472e6ebb..6da8cfccf1352 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; import type SuperTest from 'supertest'; -import { MAX_DOCS_PER_PAGE } from '@kbn/cases-plugin/common/constants'; +import { MAX_COMMENTS_PER_PAGE } from '@kbn/cases-plugin/common/constants'; import { Alerts, createCaseAttachAlertAndDeleteCase, @@ -170,7 +170,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest: supertestWithoutAuth, caseId: postedCase.id, query: { - perPage: MAX_DOCS_PER_PAGE, + perPage: MAX_COMMENTS_PER_PAGE, }, }), ]); @@ -210,14 +210,14 @@ export default ({ getService }: FtrProviderContext): void => { supertest: supertestWithoutAuth, caseId: postedCase1.id, query: { - perPage: MAX_DOCS_PER_PAGE, + perPage: MAX_COMMENTS_PER_PAGE, }, }), findAttachments({ supertest: supertestWithoutAuth, caseId: postedCase2.id, query: { - perPage: MAX_DOCS_PER_PAGE, + perPage: MAX_COMMENTS_PER_PAGE, }, }), ]); @@ -457,7 +457,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest: supertestWithoutAuth, caseId: postedCase.id, query: { - perPage: MAX_DOCS_PER_PAGE, + perPage: MAX_COMMENTS_PER_PAGE, }, auth: { user: secAllUser, space: 'space1' }, }), diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/find_comments.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/find_comments.ts index a218350ec95ca..761959db29f66 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/find_comments.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/find_comments.ts @@ -114,7 +114,7 @@ export default ({ getService }: FtrProviderContext): void => { { name: 'field is wrong type', queryParams: { perPage: true } }, { name: 'field is unknown', queryParams: { foo: 'bar' } }, { name: 'page > 10k', queryParams: { page: 10001 } }, - { name: 'perPage > 10k', queryParams: { perPage: 10001 } }, + { name: 'perPage > 100', queryParams: { perPage: 101 } }, { name: 'page * perPage > 10k', queryParams: { page: 2, perPage: 9001 } }, ]) { it(`400s when ${errorScenario.name}`, async () => { From dba2e9ba2c720e3b96dea4d301e2e2229361c94b Mon Sep 17 00:00:00 2001 From: Khristinin Nikita Date: Wed, 21 Jun 2023 15:14:03 +0200 Subject: [PATCH 16/31] RiskScoreEndgineData client + install ds and other resources for risk scoring (#158422) ## Risc score resources bootstrap Screenshot 2023-06-12 at 14 46 56 ES PR: https://github.com/elastic/elasticsearch/pull/96348 This PR introduces RiskEngineDataClient, which purpose to install resources per namespace, including ilm policy, component template, index template and datastream for risk score. Some view demo/overview of the steps we do to initialise RiskEngineDataClient and resources https://github.com/elastic/kibana/assets/7609147/bf72bbb4-56f6-46be-9b89-c96e9f33b354 For default space, it installs indexes when the security_soluition plugin is set up. For other spaces, it initialises the resource when you call `getWriter`. This data client was passed to `RequestContextFactory` So in any request, it can be called like ``` const riskEngineDataClient = (await context.securitySolution).getRiskEngineDataClient(); const spaceId = (await context.securitySolution).getSpaceId(); const riskEngineDataClientWriter = riskEngineDataClient.getWriter({ namespace: spaceId }); ``` ### What is generated 1. ILM policy `GET _ilm/policy/.risk-score-ilm-policy` ``` { ".risk-score-ilm-policy": { "version": 1, "modified_date": "2023-05-25T10:52:36.592Z", "policy": { "phases": { "hot": { "min_age": "0ms", "actions": { "rollover": { "max_age": "30d", "max_primary_shard_size": "50gb" } } } }, "_meta": { "managed": true } }, "in_use_by": { "indices": [ ".ds-risk-score.risk-score-default-2023.05.25-000001" ], "data_streams": [ "risk-score.risk-score-default" ], "composable_templates": [ ".risk-score.risk-score-default-index-template" ] } } } ``` 2. Component template for risk score mappings `GET _component_template/risk-score-mappings` ``` { "component_templates": [ { "name": "risk-score-mappings", "component_template": { "template": { "settings": {}, "mappings": { "dynamic": "strict", "properties": { "identifierField": { "type": "keyword" }, "otherScore": { "type": "float" }, "alertsScore": { "type": "float" }, "@timestamp": { "type": "date" }, "level": { "type": "keyword" }, "riskiestInputs": { "type": "nested", "properties": { "index": { "type": "keyword" }, "id": { "type": "keyword" }, "riskScore": { "type": "float" } } }, "identifierValue": { "type": "keyword" }, "totalScore": { "type": "float" }, "totalScoreNormalized": { "type": "float" } } } }, "_meta": { "managed": true } } } ] } ``` 3. Index template `GET _index_template/.risk-score.risk-score-default-index-template` ``` { "index_templates": [ { "name": ".risk-score.risk-score-default-index-template", "index_template": { "index_patterns": [ "risk-score.risk-score-default" ], "template": { "settings": { "index": { "lifecycle": { "name": ".risk-score-ilm-policy" }, "mapping": { "total_fields": { "limit": "1000" } }, "hidden": "true", "auto_expand_replicas": "0-1" } }, "mappings": { "_meta": { "managed": true, "namespace": "default", "kibana": { "version": "8.9.0" } }, "dynamic": false } }, "composed_of": [ "risk-score-mappings" ], "_meta": { "managed": true, "namespace": "default", "kibana": { "version": "8.9.0" } }, "data_stream": { "hidden": true, "allow_custom_routing": false } } } ] } ``` 4. Data stream `GET risk-score.risk-score-default` - where is `default` is space name return ``` { ".ds-risk-score.risk-score-default-2023.05.25-000001": { "aliases": {}, "mappings": { "dynamic": "false", "_meta": { "namespace": "default", "kibana": { "version": "8.9.0" }, "managed": true }, "_data_stream_timestamp": { "enabled": true }, "properties": { "@timestamp": { "type": "date" }, "alertsScore": { "type": "float" }, "identifierField": { "type": "keyword" }, "identifierValue": { "type": "keyword" }, "level": { "type": "keyword" }, "otherScore": { "type": "float" }, "riskiestInputs": { "type": "nested", "properties": { "id": { "type": "keyword" }, "index": { "type": "keyword" }, "riskScore": { "type": "float" } } }, "totalScore": { "type": "float" }, "totalScoreNormalized": { "type": "float" } } }, "settings": { "index": { "lifecycle": { "name": ".risk-engine-ilm-policy" }, "routing": { "allocation": { "include": { "_tier_preference": "data_hot" } } }, "mapping": { "total_fields": { "limit": "1000" } }, "hidden": "true", "number_of_shards": "1", "auto_expand_replicas": "0-1", "provided_name": ".ds-risk-score.risk-score-default-2023.05.25-000001", "creation_date": "1685009904171", "number_of_replicas": "0", "uuid": "_5yc7wG4Sxy88zIVqxC7yg", "version": { "created": "8090099" } } }, "data_stream": "risk-score.risk-score-default" } } ``` --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/experimental_features.ts | 5 + .../routes/__mocks__/request_context.ts | 3 + .../__mocks__/risk_engine_data_client_mock.ts | 16 ++ .../server/lib/risk_engine/configurations.ts | 97 ++++++++ .../risk_engine_data_client.test.ts | 218 ++++++++++++++++++ .../risk_engine/risk_engine_data_client.ts | 142 ++++++++++++ .../risk_engine/utils/create_datastream.ts | 206 +++++++++++++++++ .../utils/retry_transient_es_errors.test.ts | 95 ++++++++ .../utils/retry_transient_es_errors.ts | 58 +++++ .../security_solution/server/plugin.ts | 16 ++ .../server/request_context_factory.ts | 14 +- .../plugins/security_solution/server/types.ts | 2 + .../common/config.ts | 1 + .../security_and_spaces/group10/index.ts | 1 + .../group10/risk_engine_install_resources.ts | 150 ++++++++++++ 15 files changed, 1023 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/__mocks__/risk_engine_data_client_mock.ts create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/configurations.ts create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/utils/create_datastream.ts create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.ts create mode 100644 x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine_install_resources.ts diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 079ad8434f445..dd00c9b3ab190 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -117,6 +117,11 @@ export const allowedExperimentalValues = Object.freeze({ */ detectionsCoverageOverview: false, + /** + * Enable risk engine client and initialisation of datastream, component templates and mappings + */ + riskScoringPersistence: false, + /** * Enables experimental Entity Analytics HTTP endpoints */ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts index 94e688a4db079..f45a57208e915 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts @@ -34,6 +34,7 @@ import type { import { getEndpointAuthzInitialStateMock } from '../../../../../common/endpoint/service/authz/mocks'; import type { EndpointAuthz } from '../../../../../common/endpoint/types/authz'; +import { riskEngineDataClientMock } from '../../../risk_engine/__mocks__/risk_engine_data_client_mock'; export const createMockClients = () => { const core = coreMock.createRequestHandlerContext(); @@ -61,6 +62,7 @@ export const createMockClients = () => { detectionEngineHealthClient: detectionEngineHealthClientMock.create(), ruleExecutionLog: ruleExecutionLogMock.forRoutes.create(), + riskEngineDataClient: riskEngineDataClientMock.create(), }; }; @@ -139,6 +141,7 @@ const createSecuritySolutionRequestContextMock = ( // TODO: Mock EndpointInternalFleetServicesInterface and return the mocked object. throw new Error('Not implemented'); }), + getRiskEngineDataClient: jest.fn(() => clients.riskEngineDataClient), }; }; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/__mocks__/risk_engine_data_client_mock.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/__mocks__/risk_engine_data_client_mock.ts new file mode 100644 index 0000000000000..0e9f1fade7bb6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/__mocks__/risk_engine_data_client_mock.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RiskEngineDataClient } from '../risk_engine_data_client'; + +const createRiskEngineDataClientMock = () => + ({ + getWriter: jest.fn(), + initializeResources: jest.fn(), + } as unknown as jest.Mocked); + +export const riskEngineDataClientMock = { create: createRiskEngineDataClientMock }; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/configurations.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/configurations.ts new file mode 100644 index 0000000000000..64b31b2c705a5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/configurations.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { FieldMap } from '@kbn/alerts-as-data-utils'; +import type { IIndexPatternString } from './utils/create_datastream'; + +export const ilmPolicy = { + _meta: { + managed: true, + }, + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, +}; + +export const riskFieldMap: FieldMap = { + '@timestamp': { + type: 'date', + array: false, + required: false, + }, + identifierField: { + type: 'keyword', + array: false, + required: false, + }, + identifierValue: { + type: 'keyword', + array: false, + required: false, + }, + level: { + type: 'keyword', + array: false, + required: false, + }, + totalScore: { + type: 'float', + array: false, + required: false, + }, + totalScoreNormalized: { + type: 'float', + array: false, + required: false, + }, + alertsScore: { + type: 'float', + array: false, + required: false, + }, + otherScore: { + type: 'float', + array: false, + required: false, + }, + riskiestInputs: { + type: 'nested', + required: false, + }, + 'riskiestInputs.id': { + type: 'keyword', + array: false, + required: false, + }, + 'riskiestInputs.index': { + type: 'keyword', + array: false, + required: false, + }, + 'riskiestInputs.riskScore': { + type: 'float', + array: false, + required: false, + }, +} as const; + +export const ilmPolicyName = '.risk-score-ilm-policy'; +export const mappingComponentName = '.risk-score-mappings'; +export const totalFieldsLimit = 1000; + +const riskScoreBaseIndexName = 'risk-score'; + +export const getIndexPattern = (namespace: string): IIndexPatternString => ({ + template: `.${riskScoreBaseIndexName}.${riskScoreBaseIndexName}-${namespace}-index-template`, + alias: `${riskScoreBaseIndexName}.${riskScoreBaseIndexName}-${namespace}`, +}); diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts new file mode 100644 index 0000000000000..391d89b2ebf8b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts @@ -0,0 +1,218 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + createOrUpdateComponentTemplate, + createOrUpdateIlmPolicy, + createOrUpdateIndexTemplate, +} from '@kbn/alerting-plugin/server'; +import { loggingSystemMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { RiskEngineDataClient } from './risk_engine_data_client'; +import { createDataStream } from './utils/create_datastream'; + +jest.mock('@kbn/alerting-plugin/server', () => ({ + createOrUpdateComponentTemplate: jest.fn(), + createOrUpdateIlmPolicy: jest.fn(), + createOrUpdateIndexTemplate: jest.fn(), +})); + +jest.mock('./utils/create_datastream', () => ({ + createDataStream: jest.fn(), +})); + +describe('RiskEngineDataClient', () => { + let riskEngineDataClient: RiskEngineDataClient; + let logger: ReturnType; + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const totalFieldsLimit = 1000; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + const options = { + logger, + kibanaVersion: '8.9.0', + elasticsearchClientPromise: Promise.resolve(esClient), + }; + riskEngineDataClient = new RiskEngineDataClient(options); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getWriter', () => { + it('should return a writer object', async () => { + const writer = await riskEngineDataClient.getWriter({ namespace: 'default' }); + expect(writer).toBeDefined(); + expect(typeof writer?.bulk).toBe('function'); + }); + + it('should cache and return the same writer for the same namespace', async () => { + const writer1 = await riskEngineDataClient.getWriter({ namespace: 'default' }); + const writer2 = await riskEngineDataClient.getWriter({ namespace: 'default' }); + const writer3 = await riskEngineDataClient.getWriter({ namespace: 'space-1' }); + + expect(writer1).toEqual(writer2); + expect(writer2).not.toEqual(writer3); + }); + + it('should cache writer and not call initializeResources for a second tme', async () => { + const initializeResourcesSpy = jest.spyOn(riskEngineDataClient, 'initializeResources'); + await riskEngineDataClient.getWriter({ namespace: 'default' }); + await riskEngineDataClient.getWriter({ namespace: 'default' }); + expect(initializeResourcesSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('initializeResources succes', () => { + it('should initialize risk engine resources', async () => { + await riskEngineDataClient.initializeResources({ namespace: 'default' }); + + expect(createOrUpdateIlmPolicy).toHaveBeenCalledWith({ + logger, + esClient, + name: '.risk-score-ilm-policy', + policy: { + _meta: { + managed: true, + }, + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, + }, + }); + + expect(createOrUpdateComponentTemplate).toHaveBeenCalledWith({ + logger, + esClient, + template: { + name: '.risk-score-mappings', + _meta: { + managed: true, + }, + template: { + settings: {}, + mappings: { + dynamic: 'strict', + properties: { + '@timestamp': { + type: 'date', + }, + alertsScore: { + type: 'float', + }, + identifierField: { + type: 'keyword', + }, + identifierValue: { + type: 'keyword', + }, + level: { + type: 'keyword', + }, + otherScore: { + type: 'float', + }, + riskiestInputs: { + properties: { + id: { + type: 'keyword', + }, + index: { + type: 'keyword', + }, + riskScore: { + type: 'float', + }, + }, + type: 'nested', + }, + totalScore: { + type: 'float', + }, + totalScoreNormalized: { + type: 'float', + }, + }, + }, + }, + }, + totalFieldsLimit, + }); + + expect(createOrUpdateIndexTemplate).toHaveBeenCalledWith({ + logger, + esClient, + template: { + name: '.risk-score.risk-score-default-index-template', + body: { + data_stream: { hidden: true }, + index_patterns: ['risk-score.risk-score-default'], + composed_of: ['.risk-score-mappings'], + template: { + settings: { + auto_expand_replicas: '0-1', + hidden: true, + 'index.lifecycle': { + name: '.risk-score-ilm-policy', + }, + 'index.mapping.total_fields.limit': totalFieldsLimit, + }, + mappings: { + dynamic: false, + _meta: { + kibana: { + version: '8.9.0', + }, + managed: true, + namespace: 'default', + }, + }, + }, + _meta: { + kibana: { + version: '8.9.0', + }, + managed: true, + namespace: 'default', + }, + }, + }, + }); + + expect(createDataStream).toHaveBeenCalledWith({ + logger, + esClient, + totalFieldsLimit, + indexPatterns: { + template: `.risk-score.risk-score-default-index-template`, + alias: `risk-score.risk-score-default`, + }, + }); + }); + }); + + describe('initializeResources error', () => { + it('should handle errors during initialization', async () => { + const error = new Error('There error'); + (createOrUpdateIlmPolicy as jest.Mock).mockRejectedValue(error); + + await riskEngineDataClient.initializeResources({ namespace: 'default' }); + + expect(logger.error).toHaveBeenCalledWith( + `Error initializing risk engine resources: ${error.message}` + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts new file mode 100644 index 0000000000000..9b77741bb164a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { Metadata } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { ClusterPutComponentTemplateRequest } from '@elastic/elasticsearch/lib/api/types'; +import { + createOrUpdateComponentTemplate, + createOrUpdateIlmPolicy, + createOrUpdateIndexTemplate, +} from '@kbn/alerting-plugin/server'; +import { mappingFromFieldMap } from '@kbn/alerting-plugin/common'; +import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; +import type { Logger, ElasticsearchClient } from '@kbn/core/server'; +import { + riskFieldMap, + getIndexPattern, + totalFieldsLimit, + mappingComponentName, + ilmPolicyName, + ilmPolicy, +} from './configurations'; +import { createDataStream } from './utils/create_datastream'; + +interface InitializeRiskEngineResourcesOpts { + namespace?: string; +} + +interface RiskEngineDataClientOpts { + logger: Logger; + kibanaVersion: string; + elasticsearchClientPromise: Promise; +} + +interface Writer { + bulk: () => Promise; +} + +export class RiskEngineDataClient { + private writerCache: Map = new Map(); + constructor(private readonly options: RiskEngineDataClientOpts) {} + + public async getWriter({ namespace }: { namespace: string }): Promise { + if (this.writerCache.get(namespace)) { + return this.writerCache.get(namespace) as Writer; + } + + await this.initializeResources({ namespace }); + return this.writerCache.get(namespace) as Writer; + } + + private async initializeWriter(namespace: string): Promise { + const writer: Writer = { + bulk: async () => {}, + }; + this.writerCache.set(namespace, writer); + return writer; + } + + public async initializeResources({ + namespace = DEFAULT_NAMESPACE_STRING, + }: InitializeRiskEngineResourcesOpts) { + try { + const esClient = await this.options.elasticsearchClientPromise; + + const indexPatterns = getIndexPattern(namespace); + + const indexMetadata: Metadata = { + kibana: { + version: this.options.kibanaVersion, + }, + managed: true, + namespace, + }; + + await Promise.all([ + createOrUpdateIlmPolicy({ + logger: this.options.logger, + esClient, + name: ilmPolicyName, + policy: ilmPolicy, + }), + createOrUpdateComponentTemplate({ + logger: this.options.logger, + esClient, + template: { + name: mappingComponentName, + _meta: { + managed: true, + }, + template: { + settings: {}, + mappings: mappingFromFieldMap(riskFieldMap, 'strict'), + }, + } as ClusterPutComponentTemplateRequest, + totalFieldsLimit, + }), + ]); + + await createOrUpdateIndexTemplate({ + logger: this.options.logger, + esClient, + template: { + name: indexPatterns.template, + body: { + data_stream: { hidden: true }, + index_patterns: [indexPatterns.alias], + composed_of: [mappingComponentName], + template: { + settings: { + auto_expand_replicas: '0-1', + hidden: true, + 'index.lifecycle': { + name: ilmPolicyName, + }, + 'index.mapping.total_fields.limit': totalFieldsLimit, + }, + mappings: { + dynamic: false, + _meta: indexMetadata, + }, + }, + _meta: indexMetadata, + }, + }, + }); + + await createDataStream({ + logger: this.options.logger, + esClient, + totalFieldsLimit, + indexPatterns, + }); + + this.initializeWriter(namespace); + } catch (error) { + this.options.logger.error(`Error initializing risk engine resources: ${error.message}`); + } + } +} diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/utils/create_datastream.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/create_datastream.ts new file mode 100644 index 0000000000000..910ba5e887046 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/create_datastream.ts @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// This file is a copy of x-pack/plugins/alerting/server/alerts_service/lib/create_concrete_write_index.ts +// original function create index instead of datastream, and their have plan to use datastream in the future +// so we probably should remove this file and use the original when datastream will be supported + +import type { IndicesSimulateIndexTemplateResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Logger, ElasticsearchClient } from '@kbn/core/server'; +import { get } from 'lodash'; +import { retryTransientEsErrors } from './retry_transient_es_errors'; + +export interface IIndexPatternString { + template: string; + alias: string; +} + +interface ConcreteIndexInfo { + index: string; + alias: string; + isWriteIndex: boolean; +} + +interface UpdateIndexMappingsOpts { + logger: Logger; + esClient: ElasticsearchClient; + totalFieldsLimit: number; + concreteIndices: ConcreteIndexInfo[]; +} + +interface UpdateIndexOpts { + logger: Logger; + esClient: ElasticsearchClient; + totalFieldsLimit: number; + concreteIndexInfo: ConcreteIndexInfo; +} + +const updateTotalFieldLimitSetting = async ({ + logger, + esClient, + totalFieldsLimit, + concreteIndexInfo, +}: UpdateIndexOpts) => { + const { index, alias } = concreteIndexInfo; + try { + await retryTransientEsErrors( + () => + esClient.indices.putSettings({ + index, + body: { 'index.mapping.total_fields.limit': totalFieldsLimit }, + }), + { logger } + ); + return; + } catch (err) { + logger.error( + `Failed to PUT index.mapping.total_fields.limit settings for alias ${alias}: ${err.message}` + ); + throw err; + } +}; + +// This will update the mappings of backing indices but *not* the settings. This +// is due to the fact settings can be classed as dynamic and static, and static +// updates will fail on an index that isn't closed. New settings *will* be applied as part +// of the ILM policy rollovers. More info: https://github.com/elastic/kibana/pull/113389#issuecomment-940152654 +const updateUnderlyingMapping = async ({ + logger, + esClient, + concreteIndexInfo, +}: UpdateIndexOpts) => { + const { index, alias } = concreteIndexInfo; + let simulatedIndexMapping: IndicesSimulateIndexTemplateResponse; + try { + simulatedIndexMapping = await retryTransientEsErrors( + () => esClient.indices.simulateIndexTemplate({ name: index }), + { logger } + ); + } catch (err) { + logger.error( + `Ignored PUT mappings for alias ${alias}; error generating simulated mappings: ${err.message}` + ); + return; + } + + const simulatedMapping = get(simulatedIndexMapping, ['template', 'mappings']); + + if (simulatedMapping == null) { + logger.error(`Ignored PUT mappings for alias ${alias}; simulated mappings were empty`); + return; + } + + try { + await retryTransientEsErrors( + () => esClient.indices.putMapping({ index, body: simulatedMapping }), + { logger } + ); + + return; + } catch (err) { + logger.error(`Failed to PUT mapping for alias ${alias}: ${err.message}`); + throw err; + } +}; +/** + * Updates the underlying mapping for any existing concrete indices + */ +const updateIndexMappings = async ({ + logger, + esClient, + totalFieldsLimit, + concreteIndices, +}: UpdateIndexMappingsOpts) => { + logger.debug(`Updating underlying mappings for ${concreteIndices.length} indices.`); + + // Update total field limit setting of found indices + // Other index setting changes are not updated at this time + await Promise.all( + concreteIndices.map((index) => + updateTotalFieldLimitSetting({ logger, esClient, totalFieldsLimit, concreteIndexInfo: index }) + ) + ); + + // Update mappings of the found indices. + await Promise.all( + concreteIndices.map((index) => + updateUnderlyingMapping({ logger, esClient, totalFieldsLimit, concreteIndexInfo: index }) + ) + ); +}; + +interface CreateConcreteWriteIndexOpts { + logger: Logger; + esClient: ElasticsearchClient; + totalFieldsLimit: number; + indexPatterns: IIndexPatternString; +} +/** + * Create a data stream + */ +export const createDataStream = async ({ + logger, + esClient, + indexPatterns, + totalFieldsLimit, +}: CreateConcreteWriteIndexOpts) => { + logger.info(`Creating data stream - ${indexPatterns.alias}`); + + // check if a datastream already exists + let dataStreams: ConcreteIndexInfo[] = []; + try { + // Specify both the index pattern for the backing indices and their aliases + // The alias prevents the request from finding other namespaces that could match the -* pattern + const response = await retryTransientEsErrors( + () => esClient.indices.getDataStream({ name: indexPatterns.alias, expand_wildcards: 'all' }), + { logger } + ); + + dataStreams = response.data_streams.map((dataStream) => ({ + index: dataStream.name, + alias: dataStream.name, + isWriteIndex: true, + })); + + logger.debug( + `Found ${dataStreams.length} concrete indices for ${indexPatterns.alias} - ${JSON.stringify( + dataStreams + )}` + ); + } catch (error) { + // 404 is expected if no datastream have been created + if (error.statusCode !== 404) { + logger.error( + `Error fetching concrete indices for ${indexPatterns.alias} pattern - ${error.message}` + ); + throw error; + } + } + + const dataStreamsExist = dataStreams.length > 0; + + // if a concrete write datastream already exists, update the underlying mapping + if (dataStreams.length > 0) { + await updateIndexMappings({ logger, esClient, totalFieldsLimit, concreteIndices: dataStreams }); + } + + // check if a concrete write datastream already exists + if (!dataStreamsExist) { + try { + await retryTransientEsErrors( + () => + esClient.indices.createDataStream({ + name: indexPatterns.alias, + }), + { logger } + ); + } catch (error) { + logger.error(`Error creating datastream - ${error.message}`); + throw error; + } + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.test.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.test.ts new file mode 100644 index 0000000000000..2501c57776d80 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.test.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. + */ +import { loggerMock } from '@kbn/logging-mocks'; +import { errors as EsErrors } from '@elastic/elasticsearch'; + +import { retryTransientEsErrors } from './retry_transient_es_errors'; + +const logger = loggerMock.create(); +const randomDelayMultiplier = 0.01; + +describe('retryTransientErrors', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(global.Math, 'random').mockReturnValue(randomDelayMultiplier); + }); + + it("doesn't retry if operation is successful", async () => { + const esCallMock = jest.fn().mockResolvedValue('success'); + expect(await retryTransientEsErrors(esCallMock, { logger })).toEqual('success'); + expect(esCallMock).toHaveBeenCalledTimes(1); + }); + + it('logs a warning message on retry', async () => { + const esCallMock = jest + .fn() + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockResolvedValue('success'); + + await retryTransientEsErrors(esCallMock, { logger }); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn.mock.calls[0][0]).toMatch( + `Retrying Elasticsearch operation after [2s] due to error: ConnectionError: foo ConnectionError: foo` + ); + }); + + it('retries with an exponential backoff', async () => { + let attempt = 0; + const esCallMock = jest.fn(async () => { + attempt++; + if (attempt < 4) { + throw new EsErrors.ConnectionError('foo'); + } else { + return 'success'; + } + }); + + expect(await retryTransientEsErrors(esCallMock, { logger })).toEqual('success'); + expect(esCallMock).toHaveBeenCalledTimes(4); + expect(logger.warn).toHaveBeenCalledTimes(3); + expect(logger.warn.mock.calls[0][0]).toMatch( + `Retrying Elasticsearch operation after [2s] due to error: ConnectionError: foo ConnectionError: foo` + ); + expect(logger.warn.mock.calls[1][0]).toMatch( + `Retrying Elasticsearch operation after [4s] due to error: ConnectionError: foo ConnectionError: foo` + ); + expect(logger.warn.mock.calls[2][0]).toMatch( + `Retrying Elasticsearch operation after [8s] due to error: ConnectionError: foo ConnectionError: foo` + ); + }); + + it('retries each supported error type', async () => { + const errors = [ + new EsErrors.NoLivingConnectionsError('no living connection', { + warnings: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + meta: {} as any, + }), + new EsErrors.ConnectionError('no connection'), + new EsErrors.TimeoutError('timeout'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new EsErrors.ResponseError({ statusCode: 503, meta: {} as any, warnings: [] }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new EsErrors.ResponseError({ statusCode: 408, meta: {} as any, warnings: [] }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new EsErrors.ResponseError({ statusCode: 410, meta: {} as any, warnings: [] }), + ]; + + for (const error of errors) { + const esCallMock = jest.fn().mockRejectedValueOnce(error).mockResolvedValue('success'); + expect(await retryTransientEsErrors(esCallMock, { logger })).toEqual('success'); + expect(esCallMock).toHaveBeenCalledTimes(2); + } + }); + + it('does not retry unsupported errors', async () => { + const error = new Error('foo!'); + const esCallMock = jest.fn().mockRejectedValueOnce(error).mockResolvedValue('success'); + await expect(retryTransientEsErrors(esCallMock, { logger })).rejects.toThrow(error); + expect(esCallMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.ts new file mode 100644 index 0000000000000..7a3839ad3c5bc --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import { errors as EsErrors } from '@elastic/elasticsearch'; + +const MAX_ATTEMPTS = 3; + +const retryResponseStatuses = [ + 503, // ServiceUnavailable + 408, // RequestTimeout + 410, // Gone +]; + +const isRetryableError = (e: Error) => + e instanceof EsErrors.NoLivingConnectionsError || + e instanceof EsErrors.ConnectionError || + e instanceof EsErrors.TimeoutError || + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (e instanceof EsErrors.ResponseError && retryResponseStatuses.includes(e?.statusCode!)); + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const retryTransientEsErrors = async ( + esCall: () => Promise, + { + logger, + attempt = 0, + }: { + logger: Logger; + attempt?: number; + } +): Promise => { + try { + return await esCall(); + } catch (e) { + if (attempt < MAX_ATTEMPTS && isRetryableError(e)) { + const retryCount = attempt + 1; + const retryDelaySec: number = Math.min(Math.pow(2, retryCount), 30); // 2s, 4s, 8s, 16s, 30s, 30s, 30s... + + logger.warn( + `Retrying Elasticsearch operation after [${retryDelaySec}s] due to error: ${e.toString()} ${ + e.stack + }` + ); + + // delay with some randomness + await delay(retryDelaySec * 1000 * Math.random()); + return retryTransientEsErrors(esCall, { logger, attempt: retryCount }); + } + + throw e; + } +}; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index a838c83a31e2b..b9dce72f64671 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -94,6 +94,8 @@ import { ENDPOINT_FIELDS_SEARCH_STRATEGY, ENDPOINT_SEARCH_STRATEGY, } from '../common/endpoint/constants'; +import { RiskEngineDataClient } from './lib/risk_engine/risk_engine_data_client'; + import { AppFeatures } from './lib/app_features'; export type { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract'; @@ -118,6 +120,7 @@ export class Plugin implements ISecuritySolutionPlugin { private checkMetadataTransformsTask: CheckMetadataTransformsTask | undefined; private telemetryUsageCounter?: UsageCounter; private endpointContext: EndpointAppContext; + private riskEngineDataClient: RiskEngineDataClient | undefined; constructor(context: PluginInitializerContext) { const serverConfig = createConfig(context); @@ -159,6 +162,18 @@ export class Plugin implements ISecuritySolutionPlugin { this.ruleMonitoringService.setup(core, plugins); + this.riskEngineDataClient = new RiskEngineDataClient({ + logger: this.logger, + kibanaVersion: this.pluginContext.env.packageInfo.version, + elasticsearchClientPromise: core + .getStartServices() + .then(([{ elasticsearch }]) => elasticsearch.client.asInternalUser), + }); + + if (experimentalFeatures.riskScoringPersistence) { + this.riskEngineDataClient.initializeResources({}); + } + const requestContextFactory = new RequestContextFactory({ config, logger, @@ -168,6 +183,7 @@ export class Plugin implements ISecuritySolutionPlugin { ruleMonitoringService: this.ruleMonitoringService, kibanaVersion: pluginContext.env.packageInfo.version, kibanaBranch: pluginContext.env.packageInfo.branch, + riskEngineDataClient: this.riskEngineDataClient, }); const router = core.http.createRouter(); diff --git a/x-pack/plugins/security_solution/server/request_context_factory.ts b/x-pack/plugins/security_solution/server/request_context_factory.ts index e56df95a10650..c5cf4b24750a4 100644 --- a/x-pack/plugins/security_solution/server/request_context_factory.ts +++ b/x-pack/plugins/security_solution/server/request_context_factory.ts @@ -25,6 +25,7 @@ import type { import type { Immutable } from '../common/endpoint/types'; import type { EndpointAuthz } from '../common/endpoint/types/authz'; import type { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; +import type { RiskEngineDataClient } from './lib/risk_engine/risk_engine_data_client'; export interface IRequestContextFactory { create( @@ -42,6 +43,7 @@ interface ConstructorOptions { ruleMonitoringService: IRuleMonitoringService; kibanaVersion: string; kibanaBranch: string; + riskEngineDataClient: RiskEngineDataClient; } export class RequestContextFactory implements IRequestContextFactory { @@ -56,7 +58,15 @@ export class RequestContextFactory implements IRequestContextFactory { request: KibanaRequest ): Promise { const { options, appClientFactory } = this; - const { config, core, plugins, endpointAppContextService, ruleMonitoringService } = options; + const { + config, + core, + plugins, + endpointAppContextService, + ruleMonitoringService, + riskEngineDataClient, + } = options; + const { lists, ruleRegistry, security } = plugins; const [, startPlugins] = await core.getStartServices(); @@ -128,6 +138,8 @@ export class RequestContextFactory implements IRequestContextFactory { }, getInternalFleetServices: memoize(() => endpointAppContextService.getInternalFleetServices()), + + getRiskEngineDataClient: () => riskEngineDataClient, }; } } diff --git a/x-pack/plugins/security_solution/server/types.ts b/x-pack/plugins/security_solution/server/types.ts index 64211998defc5..8326d13ad03a7 100644 --- a/x-pack/plugins/security_solution/server/types.ts +++ b/x-pack/plugins/security_solution/server/types.ts @@ -29,6 +29,7 @@ import type { import type { FrameworkRequest } from './lib/framework'; import type { EndpointAuthz } from '../common/endpoint/types/authz'; import type { EndpointInternalFleetServicesInterface } from './endpoint/services/fleet'; +import type { RiskEngineDataClient } from './lib/risk_engine/risk_engine_data_client'; export { AppClient }; @@ -46,6 +47,7 @@ export interface SecuritySolutionApiRequestHandlerContext { getRacClient: (req: KibanaRequest) => Promise; getExceptionListClient: () => ExceptionListClient | null; getInternalFleetServices: () => EndpointInternalFleetServicesInterface; + getRiskEngineDataClient: () => RiskEngineDataClient; } export type SecuritySolutionRequestHandlerContext = CustomRequestHandlerContext<{ diff --git a/x-pack/test/detection_engine_api_integration/common/config.ts b/x-pack/test/detection_engine_api_integration/common/config.ts index 8454915db9a7d..c4c3c44f1c418 100644 --- a/x-pack/test/detection_engine_api_integration/common/config.ts +++ b/x-pack/test/detection_engine_api_integration/common/config.ts @@ -76,6 +76,7 @@ export function createTestConfig(options: CreateTestConfigOptions, testFiles?: s '--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true', `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'previewTelemetryUrlEnabled', + 'riskScoringPersistence', 'riskScoringRoutesEnabled', ])}`, '--xpack.task_manager.poll_interval=1000', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts index c8501050fc25c..6841648145fd3 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts @@ -37,6 +37,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./throttle')); loadTestFile(require.resolve('./ignore_fields')); loadTestFile(require.resolve('./migrations')); + loadTestFile(require.resolve('./risk_engine_install_resources')); loadTestFile(require.resolve('./risk_engine')); loadTestFile(require.resolve('./set_alert_tags')); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine_install_resources.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine_install_resources.ts new file mode 100644 index 0000000000000..a7cae20fa8b34 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine_install_resources.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 '../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const es = getService('es'); + + describe('install risk engine resources', () => { + it('should install resources on startup', async () => { + const ilmPolicyName = '.risk-score-ilm-policy'; + const componentTemplateName = '.risk-score-mappings'; + const indexTemplateName = '.risk-score.risk-score-default-index-template'; + const indexName = 'risk-score.risk-score-default'; + + const ilmPolicy = await es.ilm.getLifecycle({ + name: ilmPolicyName, + }); + + expect(ilmPolicy[ilmPolicyName].policy).to.eql({ + _meta: { + managed: true, + }, + phases: { + hot: { + min_age: '0ms', + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, + }); + + const { component_templates: componentTemplates1 } = await es.cluster.getComponentTemplate({ + name: componentTemplateName, + }); + + expect(componentTemplates1.length).to.eql(1); + const componentTemplate = componentTemplates1[0]; + + expect(componentTemplate.name).to.eql(componentTemplateName); + expect(componentTemplate.component_template.template.mappings).to.eql({ + dynamic: 'strict', + properties: { + '@timestamp': { + type: 'date', + }, + alertsScore: { + type: 'float', + }, + identifierField: { + type: 'keyword', + }, + identifierValue: { + type: 'keyword', + }, + level: { + type: 'keyword', + }, + otherScore: { + type: 'float', + }, + riskiestInputs: { + properties: { + id: { + type: 'keyword', + }, + index: { + type: 'keyword', + }, + riskScore: { + type: 'float', + }, + }, + type: 'nested', + }, + totalScore: { + type: 'float', + }, + totalScoreNormalized: { + type: 'float', + }, + }, + }); + + const { index_templates: indexTemplates } = await es.indices.getIndexTemplate({ + name: indexTemplateName, + }); + expect(indexTemplates.length).to.eql(1); + const indexTemplate = indexTemplates[0]; + expect(indexTemplate.name).to.eql(indexTemplateName); + expect(indexTemplate.index_template.index_patterns).to.eql(['risk-score.risk-score-default']); + expect(indexTemplate.index_template.composed_of).to.eql(['.risk-score-mappings']); + expect(indexTemplate.index_template.template!.mappings?.dynamic).to.eql(false); + expect(indexTemplate.index_template.template!.mappings?._meta?.managed).to.eql(true); + expect(indexTemplate.index_template.template!.mappings?._meta?.namespace).to.eql('default'); + expect(indexTemplate.index_template.template!.mappings?._meta?.kibana?.version).to.be.a( + 'string' + ); + expect(indexTemplate.index_template.template!.settings).to.eql({ + index: { + lifecycle: { + name: '.risk-score-ilm-policy', + }, + mapping: { + total_fields: { + limit: '1000', + }, + }, + hidden: 'true', + auto_expand_replicas: '0-1', + }, + }); + + const dsResponse = await es.indices.get({ + index: indexName, + }); + + const dataStream = Object.values(dsResponse).find((ds) => ds.data_stream === indexName); + + expect(dataStream?.mappings?._meta?.managed).to.eql(true); + expect(dataStream?.mappings?._meta?.namespace).to.eql('default'); + expect(dataStream?.mappings?._meta?.kibana?.version).to.be.a('string'); + expect(dataStream?.mappings?.dynamic).to.eql('false'); + + expect(dataStream?.settings?.index?.lifecycle).to.eql({ + name: '.risk-score-ilm-policy', + }); + + expect(dataStream?.settings?.index?.mapping).to.eql({ + total_fields: { + limit: '1000', + }, + }); + + expect(dataStream?.settings?.index?.hidden).to.eql('true'); + expect(dataStream?.settings?.index?.number_of_shards).to.eql(1); + expect(dataStream?.settings?.index?.auto_expand_replicas).to.eql('0-1'); + }); + }); +}; From bdf910ed50e1535ef99578a8a08b639835da3add Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Wed, 21 Jun 2023 14:15:59 +0100 Subject: [PATCH 17/31] [ML] Hiding pattern analysis button for non-time series data (#160051) The `Run pattern analysis` button should only be available for time series data. This PR adds an additional check using the dataview's `isTimeBased` method. --- .../categorize_trigger_utils.test.ts | 2 +- .../field_categorize_button/categorize_trigger_utils.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.test.ts b/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.test.ts index 79dacc2ed1fd0..a0efbaf8de869 100644 --- a/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.test.ts +++ b/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.test.ts @@ -51,7 +51,7 @@ const action: Action = { execute: () => Promise.resolve(), }; -const dataViewMock = { id: '1', toSpec: () => ({}) } as DataView; +const dataViewMock = { id: '1', toSpec: () => ({}), isTimeBased: () => true } as DataView; describe('categorize_trigger_utils', () => { afterEach(() => { diff --git a/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.ts b/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.ts index 560671c10d1c1..371425d7e9d14 100644 --- a/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.ts +++ b/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.ts @@ -46,7 +46,12 @@ export async function canCategorize( field: DataViewField, dataView: DataView | undefined ): Promise { - if (field.name === '_id' || !dataView?.id || !field.esTypes?.includes('text')) { + if ( + field.name === '_id' || + !dataView?.id || + !dataView.isTimeBased() || + !field.esTypes?.includes('text') + ) { return false; } From 9f002b9a40194a43f4c45191827931cae78be102 Mon Sep 17 00:00:00 2001 From: Saarika Bhasi <55930906+saarikabhasi@users.noreply.github.com> Date: Wed, 21 Jun 2023 09:20:19 -0400 Subject: [PATCH 18/31] [Enterprise Search] Search Application add tour in preview page (#159845) ## Summary * Adds tour near to configuration button when page loads * Uses localStorage to save tour closed/open state ### Screen Recording https://github.com/elastic/kibana/assets/55930906/4acb6142-9822-41c4-b88a-1da2d3bf0b28 --- .../engine_search_preview.tsx | 80 ++++++++++++++++--- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx index 428283df3cd86..cde84312d81f7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx @@ -9,6 +9,8 @@ import React, { useState, useMemo } from 'react'; import { useActions, useValues } from 'kea'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; + import { EuiButtonEmpty, EuiContextMenuItem, @@ -24,6 +26,7 @@ import { EuiText, EuiTextColor, EuiTitle, + EuiTourStep, } from '@elastic/eui'; import { PagingInfo, @@ -128,6 +131,11 @@ const ConfigurationPopover: React.FC = ({ const { engineData } = useValues(EngineViewLogic); const { openDeleteEngineModal } = useActions(EngineViewLogic); const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic); + const [isTourClosed, setTourClosed] = useLocalStorage( + 'search-application-tour-closed', + false + ); + return ( <> = ({ closePopover={setCloseConfiguration} button={ - {hasSchemaConflicts && } - - {i18n.translate( - 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.buttonTitle', - { - defaultMessage: 'Configuration', + {hasSchemaConflicts && ( + <> + + + + {!isTourClosed && } + + )} + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.searchApplication.searchPreview.configuration.tourContent', + { + defaultMessage: + 'Create your API key, learn about using language clients and find more resources in Connect.', + } + )} +

+ } - )} -
+ isStepOpen={!isTourClosed} + maxWidth={360} + hasArrow + step={1} + onFinish={() => { + setTourClosed(true); + }} + stepsTotal={1} + anchorPosition="downCenter" + title={i18n.translate( + 'xpack.enterpriseSearch.content.searchApplication.searchPreview.configuration.tourTitle', + { + defaultMessage: 'Review our API page to start using your search application', + } + )} + > + <> + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.buttonTitle', + { + defaultMessage: 'Configuration', + } + )} + +
} > From c9134f131aef84ac0a4d84c9472f32f7a7c80293 Mon Sep 17 00:00:00 2001 From: jennypavlova Date: Wed, 21 Jun 2023 15:50:17 +0200 Subject: [PATCH 19/31] Refactor host filtering (#160080) ## Summary This PR refactors the filtering function (replaces the `createHostsFilter` with the new `buildCombinedHostsFilter` created for the table filtering feature) ## Testing As it's not adding any new functionality testing that will require checking if the existing filters (table selection, metrics selection) and all tabs (Metrics, Logs, Alerts) are working as expected - Check if you see the content of the logs and alerts tabs Add filter using: - the hosts table (select several hosts and add filter): image - the metrics charts (right click inside the chart and add filter): image --------- Co-authored-by: Carlos Crespo --- .../metrics/hosts/components/kpis/tile.tsx | 11 ++++---- .../components/tabs/logs/logs_tab_content.tsx | 8 ++++-- .../components/tabs/metrics/metric_chart.tsx | 11 ++++---- .../metrics/hosts/hooks/use_alerts_query.ts | 7 +++-- .../infra/public/pages/metrics/hosts/utils.ts | 28 +++++-------------- 5 files changed, 30 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx index 8b7e604c6f9b9..7760fbc8bc16e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx @@ -24,7 +24,7 @@ import { useUnifiedSearchContext } from '../../hooks/use_unified_search'; import { HostsLensMetricChartFormulas } from '../../../../../common/visualizations'; import { useHostsViewContext } from '../../hooks/use_hosts_view'; import { LensWrapper } from '../chart/lens_wrapper'; -import { createHostsFilter } from '../../utils'; +import { buildCombinedHostsFilter } from '../../utils'; import { useHostCountContext } from '../../hooks/use_host_count'; import { useAfterLoadedState } from '../../hooks/use_after_loaded_state'; import { TooltipContent } from '../metric_explanation/tooltip_content'; @@ -83,10 +83,11 @@ export const Tile = ({ const filters = useMemo(() => { return [ - createHostsFilter( - hostNodes.map((p) => p.name), - dataView - ), + buildCombinedHostsFilter({ + field: 'host.name', + values: hostNodes.map((p) => p.name), + dataView, + }), ]; }, [hostNodes, dataView]); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx index 8b9f99c132446..6fe796d33e909 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx @@ -15,7 +15,7 @@ import { useUnifiedSearchContext } from '../../../hooks/use_unified_search'; import { useLogsSearchUrlState } from '../../../hooks/use_logs_search_url_state'; import { LogsLinkToStream } from './logs_link_to_stream'; import { LogsSearchBar } from './logs_search_bar'; -import { createHostsFilter } from '../../../utils'; +import { buildCombinedHostsFilter } from '../../../utils'; import { useLogViewReference } from '../../../hooks/use_log_view_reference'; export const LogsTabContent = () => { @@ -25,7 +25,11 @@ export const LogsTabContent = () => { const { hostNodes, loading } = useHostsViewContext(); const hostsFilterQuery = useMemo( - () => createHostsFilter(hostNodes.map((p) => p.name)), + () => + buildCombinedHostsFilter({ + field: 'host.name', + values: hostNodes.map((p) => p.name), + }), [hostNodes] ); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx index 89f26a7ba906b..721feccd0f4f1 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx @@ -22,7 +22,7 @@ import { useMetricsDataViewContext } from '../../../hooks/use_data_view'; import { useUnifiedSearchContext } from '../../../hooks/use_unified_search'; import { HostsLensLineChartFormulas } from '../../../../../../common/visualizations'; import { useHostsViewContext } from '../../../hooks/use_hosts_view'; -import { createHostsFilter } from '../../../utils'; +import { buildCombinedHostsFilter } from '../../../utils'; import { useHostsTableContext } from '../../../hooks/use_hosts_table'; import { LensWrapper } from '../../chart/lens_wrapper'; import { useAfterLoadedState } from '../../../hooks/use_after_loaded_state'; @@ -62,10 +62,11 @@ export const MetricChart = ({ title, type, breakdownSize }: MetricChartProps) => const filters = useMemo(() => { return [ - createHostsFilter( - currentPage.map((p) => p.name), - dataView - ), + buildCombinedHostsFilter({ + field: 'host.name', + values: currentPage.map((p) => p.name), + dataView, + }), ]; }, [currentPage, dataView]); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts index 200bff521d86a..d79ee39498386 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts @@ -15,7 +15,7 @@ import { HostsState } from './use_unified_search_url_state'; import { useHostsViewContext } from './use_hosts_view'; import { AlertStatus } from '../types'; import { ALERT_STATUS_QUERY } from '../constants'; -import { createHostsFilter } from '../utils'; +import { buildCombinedHostsFilter } from '../utils'; export interface AlertsEsQuery { bool: BoolQuery; @@ -69,7 +69,10 @@ const createAlertsEsQuery = ({ const alertStatusFilter = createAlertStatusFilter(status); const dateFilter = createDateFilter(dateRange); - const hostsFilter = createHostsFilter(hostNodes.map((p) => p.name)); + const hostsFilter = buildCombinedHostsFilter({ + field: 'host.name', + values: hostNodes.map((p) => p.name), + }); const filters = [alertStatusFilter, dateFilter, hostsFilter].filter(Boolean) as Filter[]; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts index 63f0f7b172fb8..eba7b4d8ba032 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts @@ -5,9 +5,13 @@ * 2.0. */ -import { DataViewBase, Filter, isCombinedFilter } from '@kbn/es-query'; - -import { BooleanRelation, buildCombinedFilter, buildPhraseFilter } from '@kbn/es-query'; +import { + BooleanRelation, + buildCombinedFilter, + buildPhraseFilter, + Filter, + isCombinedFilter, +} from '@kbn/es-query'; import type { DataView } from '@kbn/data-views-plugin/common'; export const buildCombinedHostsFilter = ({ @@ -35,24 +39,6 @@ export const buildCombinedHostsFilter = ({ return buildCombinedFilter(BooleanRelation.OR, filtersFromValues, dataView); }; -export const createHostsFilter = (hostNames: string[], dataView?: DataViewBase): Filter => { - return { - query: { - terms: { - 'host.name': hostNames, - }, - }, - meta: dataView - ? { - value: hostNames.join(), - type: 'phrases', - params: hostNames, - index: dataView.id, - key: 'host.name', - } - : {}, - }; -}; export const retrieveFieldsFromFilter = (filters: Filter[], fields: string[] = []) => { for (const filter of filters) { if (isCombinedFilter(filter)) { From b122d398413f0a196811944ae94930f19b01b42a Mon Sep 17 00:00:00 2001 From: Bhavya RM Date: Wed, 21 Jun 2023 09:52:46 -0400 Subject: [PATCH 20/31] =?UTF-8?q?Unskipping=20fleet=5Ffunctional/apps/home?= =?UTF-8?q?/welcome=C2=B7ts=20=20test=20(#160078)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- x-pack/test/fleet_functional/apps/home/welcome.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/test/fleet_functional/apps/home/welcome.ts b/x-pack/test/fleet_functional/apps/home/welcome.ts index 87926f1803102..4498dda69197f 100644 --- a/x-pack/test/fleet_functional/apps/home/welcome.ts +++ b/x-pack/test/fleet_functional/apps/home/welcome.ts @@ -14,8 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'home']); const kibanaServer = getService('kibanaServer'); - // FLAKY: https://github.com/elastic/kibana/issues/156809 - describe.skip('Welcome interstitial', () => { + describe('Welcome interstitial', () => { before(async () => { // Need to navigate to page first to clear storage before test can be run await PageObjects.common.navigateToUrl('home', undefined); From 1149fe437fa718393ab87de1300ac733e5be0c10 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau Date: Wed, 21 Jun 2023 09:57:04 -0400 Subject: [PATCH 21/31] [RAM] Add the feature for slack api to have allowed list on channels (#159534) ## Summary This will enable our user to create a slack api connector with the ability to only allow some channels as an allowed list. Our user will only be able to edit this input if the secrets is enter like the `test` button work. image ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Lisa Cawley --- x-pack/plugins/actions/server/mocks.ts | 1 + x-pack/plugins/actions/server/plugin.ts | 4 +- .../stack_connectors/common/slack_api/lib.ts | 6 +- .../common/slack_api/schema.ts | 4 + .../common/slack_api/types.ts | 48 +++++--- .../connector_types/slack_api/slack_api.tsx | 3 +- .../slack_api/slack_connectors.test.tsx | 106 +++++++++++++++- .../slack_api/slack_connectors.tsx | 82 ++++++++++++- .../slack_api/slack_params.test.tsx | 49 ++++---- .../slack_api/slack_params.tsx | 114 +++--------------- .../connector_types/slack_api/translations.ts | 19 +++ .../slack_api/use_fetch_channels.tsx | 75 ++++++++++++ .../connector_types/slack_api/index.test.ts | 34 +++++- .../server/connector_types/slack_api/index.ts | 11 +- .../connector_types/slack_api/service.test.ts | 47 +++++++- .../connector_types/slack_api/service.ts | 108 ++++++++++++++--- .../plugins/stack_connectors/server/plugin.ts | 11 +- .../server/routes/get_slack_api_channels.ts | 109 +++++++++++++++++ .../stack_connectors/server/routes/index.ts | 1 + .../translations/translations/fr-FR.json | 6 +- .../translations/translations/ja-JP.json | 6 +- .../translations/translations/zh-CN.json | 6 +- .../components/simple_connector_form.tsx | 81 ++++++++++--- 23 files changed, 726 insertions(+), 205 deletions(-) create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/slack_api/use_fetch_channels.tsx create mode 100644 x-pack/plugins/stack_connectors/server/routes/get_slack_api_channels.ts diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 93b77dc7c4378..d14c082f96905 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -30,6 +30,7 @@ const createSetupMock = () => { getSubActionConnectorClass: jest.fn(), getCaseConnectorClass: jest.fn(), getActionsHealth: jest.fn(), + getActionsConfigurationUtilities: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index b044babaa22e1..8e44714129557 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -67,7 +67,7 @@ import { ActionsRequestHandlerContext, } from './types'; -import { getActionsConfigurationUtilities } from './actions_config'; +import { ActionsConfigurationUtilities, getActionsConfigurationUtilities } from './actions_config'; import { defineRoutes } from './routes'; import { initializeActionsTelemetry, scheduleActionsTelemetry } from './usage/task'; @@ -129,6 +129,7 @@ export interface PluginSetupContract { getSubActionConnectorClass: () => IServiceAbstract; getCaseConnectorClass: () => IServiceAbstract; getActionsHealth: () => { hasPermanentEncryptionKey: boolean }; + getActionsConfigurationUtilities: () => ActionsConfigurationUtilities; } export interface PluginStartContract { @@ -370,6 +371,7 @@ export class ActionsPlugin implements Plugin actionsConfigUtils, }; } diff --git a/x-pack/plugins/stack_connectors/common/slack_api/lib.ts b/x-pack/plugins/stack_connectors/common/slack_api/lib.ts index 449b1aef56b14..38f9cd0609897 100644 --- a/x-pack/plugins/stack_connectors/common/slack_api/lib.ts +++ b/x-pack/plugins/stack_connectors/common/slack_api/lib.ts @@ -8,10 +8,10 @@ import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types'; import { i18n } from '@kbn/i18n'; -export function successResult( +export function successResult( actionId: string, - data: unknown -): ConnectorTypeExecutorResult { + data: T +): ConnectorTypeExecutorResult { return { status: 'ok', data, actionId }; } diff --git a/x-pack/plugins/stack_connectors/common/slack_api/schema.ts b/x-pack/plugins/stack_connectors/common/slack_api/schema.ts index e257ee20a3d7c..3a96528ba2801 100644 --- a/x-pack/plugins/stack_connectors/common/slack_api/schema.ts +++ b/x-pack/plugins/stack_connectors/common/slack_api/schema.ts @@ -11,6 +11,10 @@ export const SlackApiSecretsSchema = schema.object({ token: schema.string({ minLength: 1 }), }); +export const SlackApiConfigSchema = schema.object({ + allowedChannels: schema.maybe(schema.arrayOf(schema.string())), +}); + export const GetChannelsParamsSchema = schema.object({ subAction: schema.literal('getChannels'), }); diff --git a/x-pack/plugins/stack_connectors/common/slack_api/types.ts b/x-pack/plugins/stack_connectors/common/slack_api/types.ts index 1098d40eded19..6d87b078b8c15 100644 --- a/x-pack/plugins/stack_connectors/common/slack_api/types.ts +++ b/x-pack/plugins/stack_connectors/common/slack_api/types.ts @@ -14,52 +14,64 @@ import { PostMessageSubActionParamsSchema, SlackApiSecretsSchema, SlackApiParamsSchema, + SlackApiConfigSchema, } from './schema'; export type SlackApiSecrets = TypeOf; +export type SlackApiConfig = TypeOf; export type PostMessageParams = TypeOf; export type PostMessageSubActionParams = TypeOf; export type SlackApiParams = TypeOf; -export type SlackApiConnectorType = ConnectorType<{}, SlackApiSecrets, SlackApiParams, unknown>; +export type SlackApiConnectorType = ConnectorType< + SlackApiConfig, + SlackApiSecrets, + SlackApiParams, + unknown +>; export type SlackApiExecutorOptions = ConnectorTypeExecutorOptions< - {}, + SlackApiConfig, SlackApiSecrets, SlackApiParams >; export type SlackExecutorOptions = ConnectorTypeExecutorOptions< - {}, + SlackApiConfig, SlackApiSecrets, SlackApiParams >; export type SlackApiActionParams = TypeOf; -export interface GetChannelsResponse { - ok: true; - error?: string; - channels?: Array<{ - id: string; - name: string; - is_channel: boolean; - is_archived: boolean; - is_private: boolean; - }>; -} - -export interface PostMessageResponse { +export interface SlackAPiResponse { ok: boolean; - channel?: string; error?: string; message?: { text: string; }; + response_metadata?: { + next_cursor: string; + }; +} + +export interface ChannelsResponse { + id: string; + name: string; + is_channel: boolean; + is_archived: boolean; + is_private: boolean; +} +export interface GetChannelsResponse extends SlackAPiResponse { + channels?: ChannelsResponse[]; +} + +export interface PostMessageResponse extends SlackAPiResponse { + channel?: string; } export interface SlackApiService { - getChannels: () => Promise>; + getChannels: () => Promise>; postMessage: ({ channels, text, diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx index 6b985dbb90e34..102595ebb89b8 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx @@ -20,13 +20,14 @@ import type { SlackApiActionParams, SlackApiSecrets, PostMessageParams, + SlackApiConfig, } from '../../../common/slack_api/types'; import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants'; import { SlackActionParams } from '../types'; import { subtype } from '../slack/slack'; export const getConnectorType = (): ConnectorTypeModel< - unknown, + SlackApiConfig, SlackApiSecrets, PostMessageParams > => ({ diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx index ef9877c5a8772..8346e4b07c697 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx @@ -7,29 +7,57 @@ import React from 'react'; import { act, render, fireEvent, screen } from '@testing-library/react'; -import SlackActionFields from './slack_connectors'; +import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; + import { ConnectorFormTestProvider, waitForComponentToUpdate } from '../lib/test_utils'; +import SlackActionFields from './slack_connectors'; +import { useFetchChannels } from './use_fetch_channels'; jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); +jest.mock('./use_fetch_channels'); + +(useKibana as jest.Mock).mockImplementation(() => ({ + services: { + docLinks: { + links: { + alerting: { slackApiAction: 'url' }, + }, + }, + notifications: { + toasts: { + addSuccess: jest.fn(), + addDanger: jest.fn(), + }, + }, + }, +})); + +(useFetchChannels as jest.Mock).mockImplementation(() => ({ + channels: [], + isLoading: false, +})); describe('SlackActionFields renders', () => { const onSubmit = jest.fn(); beforeEach(() => { jest.clearAllMocks(); }); + it('all connector fields is rendered for web_api type', async () => { const actionConnector = { secrets: { token: 'some token', }, + config: { + allowedChannels: ['foo', 'bar'], + }, id: 'test', actionTypeId: '.slack', name: 'slack', - config: {}, isDeprecated: false, }; - render( + const { container } = render( {}} /> @@ -37,6 +65,17 @@ describe('SlackActionFields renders', () => { expect(screen.getByTestId('secrets.token-input')).toBeInTheDocument(); expect(screen.getByTestId('secrets.token-input')).toHaveValue('some token'); + expect(screen.getByTestId('config.allowedChannels-input')).toBeInTheDocument(); + const allowedChannels: string[] = []; + container + .querySelectorAll('[data-test-subj="config.allowedChannels-input"] .euiBadge') + .forEach((node) => { + const channel = node.getAttribute('title'); + if (channel) { + allowedChannels.push(channel); + } + }); + expect(allowedChannels).toEqual(['foo', 'bar']); }); it('connector validation succeeds when connector config is valid for Web API type', async () => { @@ -66,6 +105,9 @@ describe('SlackActionFields renders', () => { secrets: { token: 'some token', }, + config: { + allowedChannels: [], + }, id: 'test', actionTypeId: '.slack', name: 'slack', @@ -74,4 +116,62 @@ describe('SlackActionFields renders', () => { isValid: true, }); }); + + it('Allowed Channels combobox should be disable when there is NO token', async () => { + const actionConnector = { + secrets: { + token: '', + }, + config: { + allowedChannels: ['foo', 'bar'], + }, + id: 'test', + actionTypeId: '.slack', + name: 'slack', + isDeprecated: false, + }; + + const { container } = render( + + {}} /> + + ); + expect( + container.querySelector( + '[data-test-subj="config.allowedChannels-input"].euiComboBox-isDisabled' + ) + ).toBeInTheDocument(); + }); + + it('Allowed Channels combobox should NOT be disable when there is token', async () => { + const actionConnector = { + secrets: { + token: 'qwertyuiopasdfghjklzxcvbnm', + }, + config: { + allowedChannels: ['foo', 'bar'], + }, + id: 'test', + actionTypeId: '.slack', + name: 'slack', + isDeprecated: false, + }; + + (useFetchChannels as jest.Mock).mockImplementation(() => ({ + channels: [{ label: 'foo' }, { label: 'bar' }, { label: 'hello' }, { label: 'world' }], + isLoading: false, + })); + + const { container } = render( + + {}} /> + + ); + + expect( + container.querySelector( + '[data-test-subj="config.allowedChannels-input"].euiComboBox-isDisabled' + ) + ).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx index 70d1e06501748..2caf8bff0b611 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx @@ -5,17 +5,26 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ActionConnectorFieldsProps, + ConfigFieldSchema, SecretsFieldSchema, SimpleConnectorForm, useKibana, } from '@kbn/triggers-actions-ui-plugin/public'; -import { EuiLink } from '@elastic/eui'; +import { EuiComboBoxOptionOption, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { DocLinksStart } from '@kbn/core/public'; + +import { useFormContext, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { debounce, isEmpty } from 'lodash'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as i18n from './translations'; +import { useFetchChannels } from './use_fetch_channels'; + +/** wait this many ms after the user completes typing before applying the filter input */ +const INPUT_TIMEOUT = 250; const getSecretsFormSchema = (docLinks: DocLinksStart): SecretsFieldSchema[] => [ { @@ -33,18 +42,83 @@ const getSecretsFormSchema = (docLinks: DocLinksStart): SecretsFieldSchema[] => }, ]; -const SlackActionFields: React.FC = ({ readOnly, isEdit }) => { +const getConfigFormSchemaAfterSecrets = ( + options: EuiComboBoxOptionOption[], + isLoading: boolean, + isDisabled: boolean +): ConfigFieldSchema[] => [ + { + id: 'allowedChannels', + isRequired: false, + label: i18n.ALLOWED_CHANNELS, + helpText: ( + + ), + type: 'COMBO_BOX', + euiFieldProps: { + isDisabled, + isLoading, + noSuggestions: false, + options, + }, + }, +]; + +const NO_SCHEMA: ConfigFieldSchema[] = []; + +export const SlackActionFieldsComponents: React.FC = ({ + readOnly, + isEdit, +}) => { const { docLinks } = useKibana().services; + const form = useFormContext(); + const { setFieldValue } = form; + const [formData] = useFormData({ form }); + const [authToken, setAuthToken] = useState(''); + + const { channels, isLoading } = useFetchChannels({ authToken }); + const configFormSchemaAfterSecrets = useMemo( + () => getConfigFormSchemaAfterSecrets(channels, isLoading, channels.length === 0), + [channels, isLoading] + ); + + const debounceSetToken = debounce(setAuthToken, INPUT_TIMEOUT); + useEffect(() => { + if (formData.secrets && formData.secrets.token !== authToken) { + debounceSetToken(formData.secrets.token); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formData.secrets]); + + useEffect(() => { + if (isEmpty(authToken) && channels.length > 0) { + setFieldValue('config.allowedChannels', []); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authToken]); + return ( ); }; +export const simpleConnectorQueryClient = new QueryClient(); + +const SlackActionFields: React.FC = (props) => ( + + + +); + // eslint-disable-next-line import/no-default-export export { SlackActionFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx index e8353a3bbabf3..85f909783f178 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import SlackParamsFields from './slack_params'; import type { UseSubActionParams } from '@kbn/triggers-actions-ui-plugin/public/application/hooks/use_sub_action'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; @@ -156,28 +156,33 @@ describe('SlackParamsFields renders', () => { }); test('all params fields is rendered for getChannels call', async () => { - render( - - {}} - index={0} - defaultMessage="default message" - messageVariables={[]} - /> - - ); + const WrappedComponent = () => { + return ( + + {}} + index={0} + defaultMessage="default message" + messageVariables={[]} + /> + + ); + }; + const { getByTestId } = render(); + + getByTestId('slackChannelsComboBox').click(); + getByTestId('comboBoxSearchInput').focus(); - expect(screen.getByTestId('slackChannelsButton')).toHaveTextContent('Channels'); - fireEvent.click(screen.getByTestId('slackChannelsButton')); - expect(screen.getByTestId('slackChannelsSelectableList')).toBeInTheDocument(); - expect(screen.getByTestId('slackChannelsSelectableList')).toHaveTextContent('general'); - fireEvent.click(screen.getByText('general')); - expect(screen.getByTitle('general').getAttribute('aria-checked')).toEqual('true'); + const options = getByTestId( + 'comboBoxOptionsList slackChannelsComboBox-optionsList' + ).querySelectorAll('.euiComboBoxOption__content'); + expect(options).toHaveLength(1); + expect(options[0].textContent).toBe('general'); }); test('show error message when no channel is selected', async () => { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx index 6d5f284e764b5..68172f50d51de 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx @@ -9,24 +9,10 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; import { i18n } from '@kbn/i18n'; import { TextAreaWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public'; -import { - EuiSpacer, - EuiFilterGroup, - EuiPopover, - EuiFilterButton, - EuiSelectable, - EuiSelectableOption, - EuiFormRow, -} from '@elastic/eui'; +import { EuiSpacer, EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { useSubAction, useKibana } from '@kbn/triggers-actions-ui-plugin/public'; -import { FormattedMessage } from '@kbn/i18n-react'; import type { GetChannelsResponse, PostMessageParams } from '../../../common/slack_api/types'; -interface ChannelsStatus { - label: string; - checked?: 'on'; -} - const SlackParamsFields: React.FunctionComponent> = ({ actionConnector, actionParams, @@ -85,6 +71,10 @@ const SlackParamsFields: React.FunctionComponent( + (channels ?? []).map((c) => ({ label: c })) + ); + const slackChannels = useMemo( () => channelsInfo @@ -93,44 +83,11 @@ const SlackParamsFields: React.FunctionComponent(channels ?? []); - - const button = ( - setIsPopoverOpen(!isPopoverOpen)} - numFilters={selectedChannels.length} - hasActiveFilters={selectedChannels.length > 0} - numActiveFilters={selectedChannels.length} - data-test-subj="slackChannelsButton" - > - - - ); - - const options: ChannelsStatus[] = useMemo( - () => - slackChannels.map((slackChannel) => ({ - label: slackChannel.label, - ...(selectedChannels.includes(slackChannel.label) ? { checked: 'on' } : {}), - })), - [slackChannels, selectedChannels] - ); - const onChange = useCallback( - (newOptions: EuiSelectableOption[]) => { - const newSelectedChannels = newOptions.reduce((result, option) => { - if (option.checked === 'on') { - result = [...result, option.label]; - } - return result; - }, []); + (newOptions: EuiComboBoxOptionOption[]) => { + const newSelectedChannels = newOptions.map((option) => option.label); - setSelectedChannels(newSelectedChannels); + setSelectedChannels(newOptions); editAction('subActionParams', { channels: newSelectedChannels, text }, index); }, [editAction, index, text] @@ -139,53 +96,22 @@ const SlackParamsFields: React.FunctionComponent 0 && channels.length === 0} > - - setIsPopoverOpen(false)} - > - - {(list, search) => ( - <> - {search} - - {list} - - )} - - - + => { + return http.post( + `${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/_slack_api/channels`, + { + body: JSON.stringify({ + authToken: newAuthToken, + }), + } + ); +}; + +export function useFetchChannels(props: UseFetchChannelsProps) { + const { authToken } = props; + + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const queryFn = () => { + return fetchChannels(http, authToken); + }; + + const onErrorFn = () => { + toasts.addDanger(i18n.ERROR_FETCH_CHANNELS); + }; + + const { data, isLoading, isFetching } = useQuery({ + queryKey: ['fetchChannels', authToken], + queryFn, + onError: onErrorFn, + enabled: authToken.length > 0, + refetchOnWindowFocus: false, + }); + + const channels = useMemo(() => { + return (data?.channels ?? []).map((channel: ChannelsResponse) => ({ + label: channel.name, + })); + }, [data]); + + return { + channels, + isLoading: isLoading || isFetching, + }; +} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts index 66bc3fba1219c..2b4022285dea8 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts @@ -8,7 +8,7 @@ import axios from 'axios'; import { Logger } from '@kbn/core/server'; import { Services } from '@kbn/actions-plugin/server/types'; -import { validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; +import { validateConfig, validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; import { getConnectorType } from '.'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; @@ -48,6 +48,36 @@ describe('connector registration', () => { }); }); +describe('validate config', () => { + test('should throw error when config are invalid', () => { + expect(() => { + validateConfig(connectorType, { message: 1 }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [message]: definition for this key is missing"` + ); + + expect(() => { + validateConfig(connectorType, { allowedChannels: 'foo' }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [allowedChannels]: could not parse array value from json input"` + ); + }); + + test('should validate when config are valid', () => { + expect(() => { + validateConfig( + connectorType, + { allowedChannels: ['foo', 'bar'] }, + { configurationUtilities } + ); + }).not.toThrow(); + + expect(() => { + validateConfig(connectorType, {}, { configurationUtilities }); + }).not.toThrow(); + }); +}); + describe('validate params', () => { test('should validate and throw error when params are invalid', () => { expect(() => { @@ -280,7 +310,7 @@ describe('execute', () => { configurationUtilities, logger: mockedLogger, method: 'get', - url: 'conversations.list?types=public_channel,private_channel', + url: 'conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=1000', }); expect(response).toEqual({ diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts index ee467dad3d8a2..bc3128dc666b8 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts @@ -13,14 +13,17 @@ import { import { renderMustacheString } from '@kbn/actions-plugin/server/lib/mustache_renderer'; import type { ValidatorServices } from '@kbn/actions-plugin/server/types'; import { i18n } from '@kbn/i18n'; -import { schema } from '@kbn/config-schema'; import type { SlackApiExecutorOptions, SlackApiConnectorType, SlackApiParams, SlackApiSecrets, } from '../../../common/slack_api/types'; -import { SlackApiSecretsSchema, SlackApiParamsSchema } from '../../../common/slack_api/schema'; +import { + SlackApiSecretsSchema, + SlackApiParamsSchema, + SlackApiConfigSchema, +} from '../../../common/slack_api/schema'; import { SLACK_API_CONNECTOR_ID, SLACK_URL } from '../../../common/slack_api/constants'; import { SLACK_CONNECTOR_NAME } from './translations'; import { api } from './api'; @@ -35,7 +38,7 @@ export const getConnectorType = (): SlackApiConnectorType => { name: SLACK_CONNECTOR_NAME, supportedFeatureIds: [AlertingConnectorFeatureId, SecurityConnectorFeatureId], validate: { - config: { schema: schema.object({}, { defaultValue: {} }) }, + config: { schema: SlackApiConfigSchema }, secrets: { schema: SlackApiSecretsSchema, customValidator: validateSlackUrl, @@ -80,6 +83,7 @@ const renderParameterTemplates = (params: SlackApiParams, variables: Record { logger, configurationUtilities, method: 'get', - url: 'conversations.list?types=public_channel,private_channel', + url: 'conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=1000', }); }); @@ -177,5 +177,50 @@ describe('Slack API service', () => { status: 'error', }); }); + + test('should NOT by pass allowed channels when present', async () => { + service = createExternalService( + { + secrets: { token: 'token' }, + config: { allowedChannels: ['foo', 'bar'] }, + }, + logger, + configurationUtilities + ); + + expect( + await service.postMessage({ channels: ['general', 'privat'], text: 'a message' }) + ).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + serviceMessage: + 'The channel "general,privat" is not included in the allowed channels list "foo,bar"', + message: 'error posting slack message', + status: 'error', + }); + }); + + test('should allowed channels to be persisted', async () => { + service = createExternalService( + { + secrets: { token: 'token' }, + config: { allowedChannels: ['foo', 'bar', 'general', 'privat'] }, + }, + logger, + configurationUtilities + ); + requestMock.mockImplementation(() => postMessageResponse); + + await service.postMessage({ channels: ['general', 'privat'], text: 'a message' }); + + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + method: 'post', + url: 'chat.postMessage', + data: { channel: 'general', text: 'a message' }, + }); + }); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts index 723d629a74418..a2b6ff8989880 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts @@ -18,6 +18,9 @@ import type { PostMessageSubActionParams, SlackApiService, PostMessageResponse, + GetChannelsResponse, + SlackAPiResponse, + ChannelsResponse, } from '../../../common/slack_api/types'; import { retryResultSeconds, @@ -29,13 +32,16 @@ import { import { SLACK_API_CONNECTOR_ID, SLACK_URL } from '../../../common/slack_api/constants'; import { getRetryAfterIntervalFromHeaders } from '../lib/http_response_retry_header'; +const RE_TRY = 5; +const LIMIT = 1000; + const buildSlackExecutorErrorResponse = ({ slackApiError, logger, }: { slackApiError: { message: string; - response: { + response?: { status: number; statusText: string; headers: Record; @@ -78,11 +84,11 @@ const buildSlackExecutorErrorResponse = ({ return errorResult(SLACK_API_CONNECTOR_ID, errorMessage); }; -const buildSlackExecutorSuccessResponse = ({ +const buildSlackExecutorSuccessResponse = ({ slackApiResponseData, }: { - slackApiResponseData: PostMessageResponse; -}) => { + slackApiResponseData: T; +}): ConnectorTypeExecutorResult => { if (!slackApiResponseData) { const errMessage = i18n.translate( 'xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage', @@ -96,17 +102,16 @@ const buildSlackExecutorSuccessResponse = ({ if (!slackApiResponseData.ok) { return serviceErrorResult(SLACK_API_CONNECTOR_ID, slackApiResponseData.error); } - - return successResult(SLACK_API_CONNECTOR_ID, slackApiResponseData); + return successResult(SLACK_API_CONNECTOR_ID, slackApiResponseData); }; export const createExternalService = ( - { secrets }: { secrets: { token: string } }, + { config, secrets }: { config?: { allowedChannels?: string[] }; secrets: { token: string } }, logger: Logger, configurationUtilities: ActionsConfigurationUtilities ): SlackApiService => { const { token } = secrets; - + const { allowedChannels } = config || { allowedChannels: [] }; if (!token) { throw Error(`[Action][${SLACK_CONNECTOR_NAME}]: Wrong configuration.`); } @@ -119,17 +124,73 @@ export const createExternalService = ( }, }); - const getChannels = async (): Promise> => { + const getChannels = async (): Promise< + ConnectorTypeExecutorResult + > => { try { - const result = await request({ - axios: axiosInstance, - configurationUtilities, - logger, - method: 'get', - url: 'conversations.list?types=public_channel,private_channel', - }); + const fetchChannels = (cursor: string = ''): Promise> => { + return request({ + axios: axiosInstance, + configurationUtilities, + logger, + method: 'get', + url: `conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=${LIMIT}${ + cursor.length > 0 ? `&cursor=${cursor}` : '' + }`, + }); + }; + + let numberOfFetch = 0; + let cursor = ''; + const channels: ChannelsResponse[] = []; + let result: AxiosResponse = { + data: { ok: false, channels }, + status: 0, + statusText: '', + headers: {}, + config: {}, + }; + + while (numberOfFetch < RE_TRY) { + result = await fetchChannels(cursor); + if (result.data.ok && (result.data?.channels ?? []).length > 0) { + channels.push(...(result.data?.channels ?? [])); + } + if ( + result.data.ok && + result.data.response_metadata && + result.data.response_metadata.next_cursor && + result.data.response_metadata.next_cursor.length > 0 + ) { + numberOfFetch += 1; + cursor = result.data.response_metadata.next_cursor; + } else { + break; + } + } + result.data.channels = channels; + const responseData = result.data; + if ((allowedChannels ?? []).length > 0) { + const allowedChannelsList = channels.filter((channel: ChannelsResponse) => + allowedChannels?.includes(channel.name) + ); + allowedChannels?.forEach((ac) => { + if (!allowedChannelsList.find((c: ChannelsResponse) => c.name === ac)) { + allowedChannelsList.push({ + id: '-1', + name: ac, + is_channel: true, + is_archived: false, + is_private: false, + }); + } + }); + responseData.channels = allowedChannelsList; + } - return buildSlackExecutorSuccessResponse({ slackApiResponseData: result.data }); + return buildSlackExecutorSuccessResponse({ + slackApiResponseData: responseData, + }); } catch (error) { return buildSlackExecutorErrorResponse({ slackApiError: error, logger }); } @@ -140,6 +201,19 @@ export const createExternalService = ( text, }: PostMessageSubActionParams): Promise> => { try { + if ( + allowedChannels && + allowedChannels.length > 0 && + !channels.every((c) => allowedChannels?.includes(c)) + ) { + return buildSlackExecutorErrorResponse({ + slackApiError: { + message: `The channel "${channels.join()}" is not included in the allowed channels list "${allowedChannels.join()}"`, + }, + logger, + }); + } + const result: AxiosResponse = await request({ axios: axiosInstance, method: 'post', diff --git a/x-pack/plugins/stack_connectors/server/plugin.ts b/x-pack/plugins/stack_connectors/server/plugin.ts index ce1795b4eb7fb..3e76b9adbb083 100644 --- a/x-pack/plugins/stack_connectors/server/plugin.ts +++ b/x-pack/plugins/stack_connectors/server/plugin.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { PluginInitializerContext, Plugin, CoreSetup } from '@kbn/core/server'; +import { PluginInitializerContext, Plugin, CoreSetup, Logger } from '@kbn/core/server'; import { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import { registerConnectorTypes } from './connector_types'; -import { getWellKnownEmailServiceRoute } from './routes'; +import { getSlackApiChannelsRoute, getWellKnownEmailServiceRoute } from './routes'; export interface ConnectorsPluginsSetup { actions: ActionsPluginSetupContract; } @@ -18,13 +18,18 @@ export interface ConnectorsPluginsStart { } export class StackConnectorsPlugin implements Plugin { - constructor(context: PluginInitializerContext) {} + private readonly logger: Logger; + + constructor(context: PluginInitializerContext) { + this.logger = context.logger.get(); + } public setup(core: CoreSetup, plugins: ConnectorsPluginsSetup) { const router = core.http.createRouter(); const { actions } = plugins; getWellKnownEmailServiceRoute(router); + getSlackApiChannelsRoute(router, actions.getActionsConfigurationUtilities(), this.logger); registerConnectorTypes({ actions, diff --git a/x-pack/plugins/stack_connectors/server/routes/get_slack_api_channels.ts b/x-pack/plugins/stack_connectors/server/routes/get_slack_api_channels.ts new file mode 100644 index 0000000000000..dac35c0503cbe --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/routes/get_slack_api_channels.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, + RequestHandlerContext, + KibanaRequest, + IKibanaResponse, + KibanaResponseFactory, + Logger, +} from '@kbn/core/server'; +import axios, { AxiosResponse } from 'axios'; +import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import { INTERNAL_BASE_STACK_CONNECTORS_API_PATH } from '../../common'; +import { SLACK_URL } from '../../common/slack_api/constants'; +import { ChannelsResponse, GetChannelsResponse } from '../../common/slack_api/types'; + +const bodySchema = schema.object({ + authToken: schema.string(), +}); + +const RE_TRY = 5; +const LIMIT = 1000; + +export const getSlackApiChannelsRoute = ( + router: IRouter, + configurationUtilities: ActionsConfigurationUtilities, + logger: Logger +) => { + router.post( + { + path: `${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/_slack_api/channels`, + validate: { + body: bodySchema, + }, + }, + handler + ); + + async function handler( + ctx: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise { + const { authToken } = req.body; + + const axiosInstance = axios.create({ + baseURL: SLACK_URL, + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-type': 'application/json; charset=UTF-8', + }, + }); + + const fetchChannels = (cursor: string = ''): Promise> => { + return request({ + axios: axiosInstance, + configurationUtilities, + logger, + method: 'get', + url: `conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=${LIMIT}${ + cursor.length > 0 ? `&cursor=${cursor}` : '' + }`, + }); + }; + + let numberOfFetch = 0; + let cursor = ''; + const channels: ChannelsResponse[] = []; + let result: AxiosResponse = { + data: { ok: false, channels }, + status: 0, + statusText: '', + headers: {}, + config: {}, + }; + + while (numberOfFetch < RE_TRY) { + result = await fetchChannels(cursor); + if (result.data.ok && (result.data?.channels ?? []).length > 0) { + channels.push(...(result.data?.channels ?? [])); + } + if ( + result.data.ok && + result.data.response_metadata && + result.data.response_metadata.next_cursor && + result.data.response_metadata.next_cursor.length > 0 + ) { + numberOfFetch += 1; + cursor = result.data.response_metadata.next_cursor; + } else { + break; + } + } + + return res.ok({ + body: { + ...result.data, + channels, + }, + }); + } +}; diff --git a/x-pack/plugins/stack_connectors/server/routes/index.ts b/x-pack/plugins/stack_connectors/server/routes/index.ts index 2766b99679845..df48f18480252 100644 --- a/x-pack/plugins/stack_connectors/server/routes/index.ts +++ b/x-pack/plugins/stack_connectors/server/routes/index.ts @@ -6,3 +6,4 @@ */ export { getWellKnownEmailServiceRoute } from './get_well_known_email_service'; +export { getSlackApiChannelsRoute } from './get_slack_api_channels'; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 4be90d89d6878..04666ee28108d 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -35295,10 +35295,7 @@ "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "Le message est requis.", "xpack.stackConnectors.components.slack.connectorTypeTitle": "Envoyer vers Slack", "xpack.stackConnectors.components.slack.error.invalidWebhookUrlText": "L'URL de webhook n'est pas valide.", - "xpack.stackConnectors.components.slack.loadingMessage": "Chargement des canaux", "xpack.stackConnectors.components.slack.messageTextAreaFieldLabel": "Message", - "xpack.stackConnectors.components.slack.noChannelsAvailable": "Aucun canal disponible", - "xpack.stackConnectors.components.slack.noChannelsFound": "Aucun canal trouvé", "xpack.stackConnectors.components.slack.selectMessageText": "Envoyez un message à un canal ou à un utilisateur Slack.", "xpack.stackConnectors.components.slack.webApi": "API web", "xpack.stackConnectors.components.slack.webhook": "Webhook", @@ -35451,7 +35448,6 @@ "xpack.stackConnectors.slack.configurationErrorNoHostname": "erreur lors de la configuration de l'action slack : impossible d'analyser le nom de l'hôte depuis webhookUrl", "xpack.stackConnectors.slack.errorPostingErrorMessage": "erreur lors de la publication du message slack", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "erreur lors de la publication d'un message slack, réessayer ultérieurement", - "xpack.stackConnectors.slack.params..showChannelsListButton": "Canaux", "xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed": "Impossible de récupérer la liste des canaux Slack", "xpack.stackConnectors.slack.title": "Slack", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "réponse nulle inattendue de Slack", @@ -39562,4 +39558,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "Présentation" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 663b18d425472..b6697ff42efc1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -35276,10 +35276,7 @@ "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "メッセージが必要です。", "xpack.stackConnectors.components.slack.connectorTypeTitle": "Slack に送信", "xpack.stackConnectors.components.slack.error.invalidWebhookUrlText": "Web フック URL が無効です。", - "xpack.stackConnectors.components.slack.loadingMessage": "チャンネルを読み込み中", "xpack.stackConnectors.components.slack.messageTextAreaFieldLabel": "メッセージ", - "xpack.stackConnectors.components.slack.noChannelsAvailable": "チャンネルがありません", - "xpack.stackConnectors.components.slack.noChannelsFound": "チャンネルが見つかりません", "xpack.stackConnectors.components.slack.selectMessageText": "Slack チャネルにメッセージを送信します。", "xpack.stackConnectors.components.slack.webApi": "Web API", "xpack.stackConnectors.components.slack.webhook": "Web フック", @@ -35432,7 +35429,6 @@ "xpack.stackConnectors.slack.configurationErrorNoHostname": "slack アクションの構成エラー:Web フック URL からホスト名をパースできません", "xpack.stackConnectors.slack.errorPostingErrorMessage": "slack メッセージの投稿エラー", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "slack メッセージの投稿エラー、後ほど再試行", - "xpack.stackConnectors.slack.params..showChannelsListButton": "チャンネル", "xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed": "Slackチャンネルリストを取得できませんでした", "xpack.stackConnectors.slack.title": "Slack", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "Slack から予期せぬ null 応答", @@ -39532,4 +39528,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "実地検証" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b61d162af69df..bcc76652181c4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -35270,10 +35270,7 @@ "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "“消息”必填。", "xpack.stackConnectors.components.slack.connectorTypeTitle": "发送到 Slack", "xpack.stackConnectors.components.slack.error.invalidWebhookUrlText": "Webhook URL 无效。", - "xpack.stackConnectors.components.slack.loadingMessage": "正在加载频道", "xpack.stackConnectors.components.slack.messageTextAreaFieldLabel": "消息", - "xpack.stackConnectors.components.slack.noChannelsAvailable": "无频道可用", - "xpack.stackConnectors.components.slack.noChannelsFound": "找不到频道", "xpack.stackConnectors.components.slack.selectMessageText": "向 Slack 频道或用户发送消息。", "xpack.stackConnectors.components.slack.webApi": "Web API", "xpack.stackConnectors.components.slack.webhook": "Webhook", @@ -35426,7 +35423,6 @@ "xpack.stackConnectors.slack.configurationErrorNoHostname": "配置 slack 操作时出错:无法解析 webhookUrl 中的主机名", "xpack.stackConnectors.slack.errorPostingErrorMessage": "发布 slack 消息时出错", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "发布 slack 消息时出错,稍后重试", - "xpack.stackConnectors.slack.params..showChannelsListButton": "频道", "xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed": "无法检索 Slack 频道列表", "xpack.stackConnectors.slack.title": "Slack", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "来自 slack 的异常空响应", @@ -39526,4 +39522,4 @@ "xpack.painlessLab.title": "Painless 实验室", "xpack.painlessLab.walkthroughButtonLabel": "指导" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/simple_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/simple_connector_form.tsx index d552babfcab8d..d76b76e641821 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/simple_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/simple_connector_form.tsx @@ -7,8 +7,12 @@ import React, { memo, ReactNode } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; -import { Field, PasswordField } from '@kbn/es-ui-shared-plugin/static/forms/components'; -import { getUseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { + ComboBoxField, + Field, + PasswordField, +} from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { FIELD_TYPES, getUseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; import { i18n } from '@kbn/i18n'; @@ -16,11 +20,14 @@ export interface CommonFieldSchema { id: string; label: string; helpText?: string | ReactNode; + isRequired?: boolean; + type?: keyof typeof FIELD_TYPES; + euiFieldProps?: Record; } export interface ConfigFieldSchema extends CommonFieldSchema { isUrlField?: boolean; - defaultValue?: string; + defaultValue?: string | string[]; } export interface SecretsFieldSchema extends CommonFieldSchema { @@ -32,35 +39,45 @@ interface SimpleConnectorFormProps { readOnly: boolean; configFormSchema: ConfigFieldSchema[]; secretsFormSchema: SecretsFieldSchema[]; + configFormSchemaAfterSecrets?: ConfigFieldSchema[]; } type FormRowProps = ConfigFieldSchema & SecretsFieldSchema & { readOnly: boolean }; -const UseField = getUseField({ component: Field }); +const UseTextField = getUseField({ component: Field }); +const UseComboBoxField = getUseField({ component: ComboBoxField }); const { emptyField, urlField } = fieldValidators; const getFieldConfig = ({ label, + isRequired = true, isUrlField = false, defaultValue, + type, }: { label: string; + isRequired?: boolean; isUrlField?: boolean; - defaultValue?: string; + defaultValue?: string | string[]; + type?: keyof typeof FIELD_TYPES; }) => ({ label, validations: [ - { - validator: emptyField( - i18n.translate( - 'xpack.triggersActionsUI.sections.actionConnectorForm.error.requireFieldText', + ...(isRequired + ? [ { - values: { label }, - defaultMessage: `{label} is required.`, - } - ) - ), - }, + validator: emptyField( + i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorForm.error.requireFieldText', + { + values: { label }, + defaultMessage: `{label} is required.`, + } + ) + ), + }, + ] + : []), ...(isUrlField ? [ { @@ -77,18 +94,33 @@ const getFieldConfig = ({ : []), ], defaultValue, + ...(type && FIELD_TYPES[type] + ? { type: FIELD_TYPES[type], defaultValue: Array.isArray(defaultValue) ? defaultValue : [] } + : {}), }); +const getComponentByType = (type?: keyof typeof FIELD_TYPES) => { + let UseField = UseTextField; + if (type && FIELD_TYPES[type] === FIELD_TYPES.COMBO_BOX) { + UseField = UseComboBoxField; + } + return UseField; +}; + const FormRow: React.FC = ({ id, label, readOnly, isPasswordField, + isRequired = true, isUrlField, helpText, defaultValue, + euiFieldProps = {}, + type, }) => { const dataTestSub = `${id}-input`; + const UseField = getComponentByType(type); return ( <> @@ -96,20 +128,26 @@ const FormRow: React.FC = ({ {!isPasswordField ? ( ) : ( = ({ readOnly, configFormSchema, secretsFormSchema, + configFormSchemaAfterSecrets = [], }) => { return ( <> @@ -162,6 +201,12 @@ const SimpleConnectorFormComponent: React.FC = ({ {index !== secretsFormSchema.length ? : null} ))} + {configFormSchemaAfterSecrets.map(({ id, ...restConfigSchemaAfterSecrets }, index) => ( + + + {index !== configFormSchemaAfterSecrets.length ? : null} + + ))} ); }; From 9382687c29947a658c8f63d94ed891ab008ce3bf Mon Sep 17 00:00:00 2001 From: Sean Story Date: Wed, 21 Jun 2023 09:18:01 -0500 Subject: [PATCH 22/31] Update api keys to include acl filter index (#159840) ## Summary Part of https://github.com/elastic/enterprise-search-team/issues/4304 Ensures that new API keys generated from our application include permissions not only to the document index but also to the `.search-acl-filter-*` index that corresponds to it. ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../server/lib/indices/create_api_key.test.ts | 20 ++++++++++++++++++- .../server/lib/indices/create_api_key.ts | 5 ++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/create_api_key.test.ts b/x-pack/plugins/enterprise_search/server/lib/indices/create_api_key.test.ts index d89126f549a1b..76adbbdf26f45 100644 --- a/x-pack/plugins/enterprise_search/server/lib/indices/create_api_key.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/indices/create_api_key.test.ts @@ -42,7 +42,25 @@ describe('createApiKey lib function', () => { cluster: [], index: [ { - names: [indexName], + names: [indexName, `.search-acl-filter-my-index`], + privileges: ['all'], + }, + ], + }, + }, + }); + }); + + it('works with search-* prefixed indices', async () => { + await createApiKey(request, security, 'search-test', keyName); + expect(security.authc.apiKeys.create).toHaveBeenCalledWith(request, { + name: keyName, + role_descriptors: { + ['search-test-key-role']: { + cluster: [], + index: [ + { + names: ['search-test', `.search-acl-filter-test`], privileges: ['all'], }, ], diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/create_api_key.ts b/x-pack/plugins/enterprise_search/server/lib/indices/create_api_key.ts index 0c1a62c4d30db..13d6aa142e50c 100644 --- a/x-pack/plugins/enterprise_search/server/lib/indices/create_api_key.ts +++ b/x-pack/plugins/enterprise_search/server/lib/indices/create_api_key.ts @@ -17,6 +17,9 @@ export const createApiKey = async ( indexName: string, keyName: string ) => { + // removes the "search-" prefix if present, and applies the new prefix + const aclIndexName = indexName.replace(/^(?:search-)?(.*)$/, '.search-acl-filter-$1'); + return await security.authc.apiKeys.create(request, { name: keyName, role_descriptors: { @@ -24,7 +27,7 @@ export const createApiKey = async ( cluster: [], index: [ { - names: [indexName], + names: [indexName, aclIndexName], privileges: ['all'], }, ], From 513403e6a644919b53fe85c44d43f688d83774e5 Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Wed, 21 Jun 2023 15:26:42 +0100 Subject: [PATCH 23/31] [APM] Update APM general settings (#159999) - Removes AWS Lambda Metrics (observability:enableAwsLambdaMetrics) as it's available in labs - Removes Infrastructure hosts (view observability:enableInfrastructureHostsView) as it's not related to APM - Removes the callout that points to advanced settings image Closes to https://github.com/elastic/kibana/issues/159911 --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../app/settings/general_settings/index.tsx | 36 ++----------------- .../translations/translations/fr-FR.json | 3 -- .../translations/translations/ja-JP.json | 3 -- .../translations/translations/zh-CN.json | 3 -- 4 files changed, 2 insertions(+), 43 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/settings/general_settings/index.tsx b/x-pack/plugins/apm/public/components/app/settings/general_settings/index.tsx index 7604bd6781329..a39c86be9de12 100644 --- a/x-pack/plugins/apm/public/components/app/settings/general_settings/index.tsx +++ b/x-pack/plugins/apm/public/components/app/settings/general_settings/index.tsx @@ -5,10 +5,9 @@ * 2.0. */ -import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { LazyField } from '@kbn/advanced-settings-plugin/public'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import { apmLabsButton, apmServiceGroupMaxNumberOfServices, @@ -19,8 +18,6 @@ import { apmAWSLambdaRequestCostPerMillion, apmEnableServiceMetrics, apmEnableContinuousRollups, - enableInfrastructureHostsView, - enableAwsLambdaMetrics, enableAgentExplorerView, } from '@kbn/observability-plugin/common'; import { isEmpty } from 'lodash'; @@ -39,13 +36,11 @@ const apmSettingsKeys = [ apmAWSLambdaRequestCostPerMillion, apmEnableServiceMetrics, apmEnableContinuousRollups, - enableInfrastructureHostsView, - enableAwsLambdaMetrics, enableAgentExplorerView, ]; export function GeneralSettings() { - const { docLinks, notifications, application } = useApmPluginContext().core; + const { docLinks, notifications } = useApmPluginContext().core; const { handleFieldChange, settingsEditableConfig, @@ -77,33 +72,6 @@ export function GeneralSettings() { return ( <> - -

- - {i18n.translate('xpack.apm.apmSettings.kibanaLink.label', { - defaultMessage: 'Kibana advanced settings.', - })} - - ), - }} - /> -

-
{apmSettingsKeys.map((settingKey) => { const editableConfig = settingsEditableConfig[settingKey]; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 04666ee28108d..d1a13fec106d7 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -7409,7 +7409,6 @@ "xpack.apm.anomalyDetection.createJobs.failed.text": "Une erreur est survenue lors de la création d'une ou de plusieurs tâches de détection des anomalies pour les environnements de service APM [{environments}]. Erreur : \"{errorMessage}\"", "xpack.apm.anomalyDetection.createJobs.succeeded.text": "Tâches de détection des anomalies créées avec succès pour les environnements de service APM [{environments}]. Le démarrage de l'analyse du trafic à la recherche d'anomalies par le Machine Learning va prendre un certain temps.", "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "La détection des anomalies n'est pas encore activée pour l'environnement \"{currentEnvironment}\". Cliquez pour continuer la configuration.", - "xpack.apm.apmSettings.kibanaLink": "La liste complète d'options APM est disponible dans {link}", "xpack.apm.bottomBarActions.unsavedChanges": "{unsavedChangesCount, plural, =0 {0 modification non enregistrée} one {1 modification non enregistrée} many {# modifications non enregistrées} other {# modifications non enregistrées}} ", "xpack.apm.compositeSpanCallsLabel": ", {count} appels, sur une moyenne de {duration}", "xpack.apm.correlations.ccsWarningCalloutBody": "Les données pour l'analyse de corrélation n'ont pas pu être totalement récupérées. Cette fonctionnalité est prise en charge uniquement pour {version} et versions ultérieures.", @@ -7779,9 +7778,7 @@ "xpack.apm.apmDescription": "Collecte automatiquement les indicateurs et les erreurs de performances détaillés depuis vos applications.", "xpack.apm.apmSchema.index": "Schéma du serveur APM - Index", "xpack.apm.apmServiceGroups.title": "Groupes de services APM", - "xpack.apm.apmSettings.callOutTitle": "Vous recherchez tous les paramètres ?", "xpack.apm.apmSettings.index": "Paramètres APM - Index", - "xpack.apm.apmSettings.kibanaLink.label": "Paramètres avancés de Kibana.", "xpack.apm.apmSettings.save.error": "Une erreur s'est produite lors de l'enregistrement des paramètres", "xpack.apm.apmSettings.saveButton": "Enregistrer les modifications", "xpack.apm.appName": "APM", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b6697ff42efc1..497b63577437d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7410,7 +7410,6 @@ "xpack.apm.anomalyDetection.createJobs.failed.text": "APMサービス環境[{environments}]用に1つ以上の異常検知ジョブを作成しているときに問題が発生しました。エラー「{errorMessage}」", "xpack.apm.anomalyDetection.createJobs.succeeded.text": "APMサービス環境[{environments}]の異常検知ジョブが正常に作成されました。機械学習がトラフィック異常値の分析を開始するには、少し時間がかかります。", "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "「{currentEnvironment}」環境では、まだ異常検知が有効ではありません。クリックすると、セットアップを続行します。", - "xpack.apm.apmSettings.kibanaLink": "APMオプションの一覧は{link}を参照してください", "xpack.apm.bottomBarActions.unsavedChanges": "{unsavedChangesCount, plural, =0 {0個の保存されていない変更} other {#個の保存されていない変更}} ", "xpack.apm.compositeSpanCallsLabel": "、{count}件の呼び出し、平均:{duration}", "xpack.apm.correlations.ccsWarningCalloutBody": "相関関係分析のデータを完全に取得できませんでした。この機能はバージョン{version}以降でのみサポートされています。", @@ -7780,9 +7779,7 @@ "xpack.apm.apmDescription": "アプリケーション内から自動的に詳細なパフォーマンスメトリックやエラーを集めます。", "xpack.apm.apmSchema.index": "APMサーバースキーマ - インデックス", "xpack.apm.apmServiceGroups.title": "APMサービスグループ", - "xpack.apm.apmSettings.callOutTitle": "すべての設定を探している場合", "xpack.apm.apmSettings.index": "APM 設定 - インデックス", - "xpack.apm.apmSettings.kibanaLink.label": "Kibana詳細設定。", "xpack.apm.apmSettings.save.error": "設定の保存中にエラーが発生しました", "xpack.apm.apmSettings.saveButton": "変更を保存", "xpack.apm.appName": "APM", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index bcc76652181c4..f4618236706fd 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7409,7 +7409,6 @@ "xpack.apm.anomalyDetection.createJobs.failed.text": "为 APM 服务环境 [{environments}] 创建一个或多个异常检测作业时出现问题。错误:“{errorMessage}”", "xpack.apm.anomalyDetection.createJobs.succeeded.text": "APM 服务环境 [{environments}] 的异常检测作业已成功创建。Machine Learning 要过一些时间才会开始分析流量以发现异常。", "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "尚未针对环境“{currentEnvironment}”启用异常检测。单击可继续设置。", - "xpack.apm.apmSettings.kibanaLink": "可在 {link} 中找到 APM 选项的完整列表", "xpack.apm.bottomBarActions.unsavedChanges": "{unsavedChangesCount, plural, =0 {0 个未保存更改} other {# 个未保存更改}} ", "xpack.apm.compositeSpanCallsLabel": ",{count} 个调用,平均 {duration}", "xpack.apm.correlations.ccsWarningCalloutBody": "无法完全检索相关性分析的数据。仅 {version} 及更高版本支持此功能。", @@ -7779,9 +7778,7 @@ "xpack.apm.apmDescription": "自动从您的应用程序内收集深层的性能指标和错误。", "xpack.apm.apmSchema.index": "APM Server 架构 - 索引", "xpack.apm.apmServiceGroups.title": "APM 服务组", - "xpack.apm.apmSettings.callOutTitle": "正在查找所有设置?", "xpack.apm.apmSettings.index": "APM 设置 - 索引", - "xpack.apm.apmSettings.kibanaLink.label": "Kibana 高级设置。", "xpack.apm.apmSettings.save.error": "保存设置时出错", "xpack.apm.apmSettings.saveButton": "保存更改", "xpack.apm.appName": "APM", From 0c326673b984d5575c463e299c96bee486f42491 Mon Sep 17 00:00:00 2001 From: Konrad Szwarc Date: Wed, 21 Jun 2023 16:54:31 +0200 Subject: [PATCH 24/31] [Fleet][BUG] Fix default output overwrite (#159894) This pull request resolves a bug in the fleet's API that permits setting the default output to `is_default: false` and `is_default_monitoring: false`, resulting in the absence of a default output. This action is not allowed through the UI as the toggle is disabled. The objective is to determine whether the update method was called directly, fulfilling the API request, or if it was utilized as a helper by either the `create` or `update` methods themselves. --- .../fleet/server/services/output.test.ts | 46 +++++++ .../plugins/fleet/server/services/output.ts | 112 ++++++++++++------ 2 files changed, 122 insertions(+), 36 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts index 16a27abb0b330..588f686a6eaf7 100644 --- a/x-pack/plugins/fleet/server/services/output.test.ts +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -82,6 +82,12 @@ function getMockedSoClient( type: 'elasticsearch', }); } + case outputIdToUuid('existing-default-and-default-monitoring-output'): { + return mockOutputSO('existing-default-and-default-monitoring-output', { + is_default: true, + is_default_monitoring: true, + }); + } case outputIdToUuid('existing-preconfigured-default-output'): { return mockOutputSO('existing-preconfigured-default-output', { is_default: true, @@ -569,6 +575,46 @@ describe('Output Service', () => { ); }); + it('should not set default output to false when the output is already the default one', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'existing-default-and-default-monitoring-output', + }); + + await expect( + outputService.update( + soClient, + esClientMock, + 'existing-default-and-default-monitoring-output', + { + is_default: false, + name: 'Test', + } + ) + ).rejects.toThrow( + `Default output existing-default-and-default-monitoring-output cannot be set to is_default=false or is_default_monitoring=false manually. Make another output the default first.` + ); + }); + + it('should not set default monitoring output to false when the output is already the default one', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'existing-default-and-default-monitoring-output', + }); + + await expect( + outputService.update( + soClient, + esClientMock, + 'existing-default-and-default-monitoring-output', + { + is_default_monitoring: false, + name: 'Test', + } + ) + ).rejects.toThrow( + `Default output existing-default-and-default-monitoring-output cannot be set to is_default=false or is_default_monitoring=false manually. Make another output the default first.` + ); + }); + it('should update existing default monitoring output when updating an output to become the default monitoring output', async () => { const soClient = getMockedSoClient({ defaultOutputMonitoringId: 'existing-default-monitoring-output', diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index 50fb7b54a5346..afa92127dae97 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -9,27 +9,27 @@ import { omit } from 'lodash'; import { safeLoad } from 'js-yaml'; import deepEqual from 'fast-deep-equal'; -import { SavedObjectsUtils } from '@kbn/core/server'; import type { + ElasticsearchClient, KibanaRequest, SavedObject, SavedObjectsClientContract, - ElasticsearchClient, } from '@kbn/core/server'; +import { SavedObjectsUtils } from '@kbn/core/server'; -import type { NewOutput, Output, OutputSOAttributes, AgentPolicy } from '../types'; +import type { AgentPolicy, NewOutput, Output, OutputSOAttributes } from '../types'; import { + AGENT_POLICY_SAVED_OBJECT_TYPE, DEFAULT_OUTPUT, DEFAULT_OUTPUT_ID, OUTPUT_SAVED_OBJECT_TYPE, - AGENT_POLICY_SAVED_OBJECT_TYPE, } from '../constants'; -import { SO_SEARCH_LIMIT, outputType } from '../../common/constants'; +import { outputType, SO_SEARCH_LIMIT } from '../../common/constants'; import { normalizeHostsForAgents } from '../../common/services'; import { - OutputUnauthorizedError, - OutputInvalidError, FleetEncryptedSavedObjectEncryptionKeyRequired, + OutputInvalidError, + OutputUnauthorizedError, } from '../errors'; import { agentPolicyService } from './agent_policy'; @@ -261,6 +261,59 @@ class OutputService { return outputs; } + private async _updateDefaultOutput( + soClient: SavedObjectsClientContract, + defaultDataOutputId: string, + updateData: { is_default: boolean } | { is_default_monitoring: boolean }, + fromPreconfiguration: boolean + ) { + const originalOutput = await this.get(soClient, defaultDataOutputId); + this._validateFieldsAreEditable( + originalOutput, + updateData, + defaultDataOutputId, + fromPreconfiguration + ); + + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: outputIdToUuid(defaultDataOutputId), + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + + return await this.encryptedSoClient.update>( + SAVED_OBJECT_TYPE, + outputIdToUuid(defaultDataOutputId), + updateData + ); + } + + private _validateFieldsAreEditable( + originalOutput: Output, + data: Partial, + id: string, + fromPreconfiguration: boolean + ) { + if (originalOutput.is_preconfigured) { + if (!fromPreconfiguration) { + const allowEditFields = originalOutput.allow_edit ?? []; + + const allKeys = Array.from(new Set([...Object.keys(data)])) as Array; + for (const key of allKeys) { + if ( + (!!originalOutput[key] || !!data[key]) && + !allowEditFields.includes(key) && + !deepEqual(originalOutput[key], data[key]) + ) { + throw new OutputUnauthorizedError( + `Preconfigured output ${id} ${key} cannot be updated outside of kibana config file.` + ); + } + } + } + } + } + public async ensureDefaultOutput( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient @@ -351,24 +404,22 @@ class OutputService { // ensure only default output exists if (data.is_default) { if (defaultDataOutputId) { - await this.update( + await this._updateDefaultOutput( soClient, - esClient, defaultDataOutputId, { is_default: false }, - { fromPreconfiguration: options?.fromPreconfiguration ?? false } + options?.fromPreconfiguration ?? false ); } } if (data.is_default_monitoring) { const defaultMonitoringOutputId = await this.getDefaultMonitoringOutputId(soClient); if (defaultMonitoringOutputId) { - await this.update( + await this._updateDefaultOutput( soClient, - esClient, defaultMonitoringOutputId, { is_default_monitoring: false }, - { fromPreconfiguration: options?.fromPreconfiguration ?? false } + options?.fromPreconfiguration ?? false ); } } @@ -554,29 +605,20 @@ class OutputService { } ) { const originalOutput = await this.get(soClient, id); - if (originalOutput.is_preconfigured) { - if (!fromPreconfiguration) { - const allowEditFields = originalOutput.allow_edit ?? []; - const allKeys = Array.from(new Set([...Object.keys(data)])) as Array; - for (const key of allKeys) { - if ( - (!!originalOutput[key] || !!data[key]) && - !allowEditFields.includes(key) && - !deepEqual(originalOutput[key], data[key]) - ) { - throw new OutputUnauthorizedError( - `Preconfigured output ${id} ${key} cannot be updated outside of kibana config file.` - ); - } - } - } + this._validateFieldsAreEditable(originalOutput, data, id, fromPreconfiguration); + if ( + (originalOutput.is_default && data.is_default === false) || + (data.is_default_monitoring === false && originalOutput.is_default_monitoring) + ) { + throw new OutputUnauthorizedError( + `Default output ${id} cannot be set to is_default=false or is_default_monitoring=false manually. Make another output the default first.` + ); } const updateData: Nullable> = { ...omit(data, 'ssl') }; const mergedType = data.type ?? originalOutput.type; const defaultDataOutputId = await this.getDefaultDataOutputId(soClient); - await validateTypeChanges( soClient, esClient, @@ -609,12 +651,11 @@ class OutputService { // ensure only default output exists if (data.is_default) { if (defaultDataOutputId && defaultDataOutputId !== id) { - await this.update( + await this._updateDefaultOutput( soClient, - esClient, defaultDataOutputId, { is_default: false }, - { fromPreconfiguration } + fromPreconfiguration ); } } @@ -622,12 +663,11 @@ class OutputService { const defaultMonitoringOutputId = await this.getDefaultMonitoringOutputId(soClient); if (defaultMonitoringOutputId && defaultMonitoringOutputId !== id) { - await this.update( + await this._updateDefaultOutput( soClient, - esClient, defaultMonitoringOutputId, { is_default_monitoring: false }, - { fromPreconfiguration } + fromPreconfiguration ); } } From 53dc6d28fe1edaaf7c0a411c1190c4891da0c525 Mon Sep 17 00:00:00 2001 From: Jordan <51442161+JordanSh@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:04:50 +0300 Subject: [PATCH 25/31] [Cloud Security] Trend graph per account (#159971) --- .../cloud_security_posture/common/types.ts | 17 +- .../public/components/chart_panel.tsx | 4 +- .../_mocks_/vulnerability_dashboard.mock.ts | 228 ++++++++---------- .../vulnerability_trend_graph.tsx | 114 ++++++++- .../get_vulnerabilities_trend.ts | 14 +- .../server/tasks/findings_stats_task.ts | 78 ++++-- .../server/tasks/types.ts | 22 ++ 7 files changed, 317 insertions(+), 160 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/common/types.ts b/x-pack/plugins/cloud_security_posture/common/types.ts index c0e6286b594c0..4f3fd3434617c 100644 --- a/x-pack/plugins/cloud_security_posture/common/types.ts +++ b/x-pack/plugins/cloud_security_posture/common/types.ts @@ -131,13 +131,26 @@ export interface GetCspRuleTemplateResponse { // CNVM DASHBOARD -export interface VulnScoreTrend { +interface AccountVulnStats { + cloudAccountId: string; + cloudAccountName: string; + critical: number; + high: number; + medium: number; + low: number; +} + +export interface VulnStatsTrend { '@timestamp': string; policy_template: 'vuln_mgmt'; critical: number; high: number; medium: number; low: number; + vulnerabilities_stats_by_cloud_account?: Record< + AccountVulnStats['cloudAccountId'], + AccountVulnStats + >; } export interface CnvmStatistics { @@ -150,7 +163,7 @@ export interface CnvmStatistics { export interface CnvmDashboardData { cnvmStatistics: CnvmStatistics; - vulnTrends: VulnScoreTrend[]; + vulnTrends: VulnStatsTrend[]; topVulnerableResources: VulnerableResourceStat[]; topPatchableVulnerabilities: PatchableVulnerabilityStat[]; topVulnerabilities: VulnerabilityStat[]; diff --git a/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx b/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx index e0ec83af0d3ba..b6ffc9f0157b8 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx @@ -74,7 +74,9 @@ export const ChartPanel: React.FC = ({ )} - {rightSideItems} + + {rightSideItems} +
{renderChart()} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/_mocks_/vulnerability_dashboard.mock.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/_mocks_/vulnerability_dashboard.mock.ts index 05b73d74e6cec..6d7f909a82e53 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/_mocks_/vulnerability_dashboard.mock.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/_mocks_/vulnerability_dashboard.mock.ts @@ -9,115 +9,81 @@ import { CnvmDashboardData } from '../../../../common/types'; export const mockCnvmDashboardData: CnvmDashboardData = { cnvmStatistics: { - criticalCount: 84, - highCount: 4595, - mediumCount: 12125, + criticalCount: 101, + highCount: 4715, + mediumCount: 10537, resourcesScanned: 81, cloudRegions: 1, }, vulnTrends: [ { - high: 4634, + high: 4754, policy_template: 'vuln_mgmt', - medium: 12522, - '@timestamp': '2023-06-15T23:49:46.563935Z', - critical: 84, - low: 4147, - }, - { - high: 4634, - policy_template: 'vuln_mgmt', - medium: 12522, - '@timestamp': '2023-06-16T23:50:14.640737Z', - critical: 84, - low: 4147, - }, - { - high: 4634, - policy_template: 'vuln_mgmt', - medium: 12522, - '@timestamp': '2023-06-17T23:53:51.851500Z', - critical: 84, - low: 4147, - }, - { - high: 4634, - policy_template: 'vuln_mgmt', - medium: 12522, - '@timestamp': '2023-06-18T23:53:50.831704Z', - critical: 84, - low: 4147, - }, - { - high: 4634, - policy_template: 'vuln_mgmt', - medium: 12522, - '@timestamp': '2023-06-19T08:28:22.830830Z', - critical: 84, - low: 4147, + '@timestamp': '2023-06-21T12:22:57.515212Z', + critical: 101, + low: 3657, + vulnerabilities_stats_by_cloud_account: { + '704479110758': { + cloudAccountName: 'elastic-security-cloud-security-dev', + high: 4754, + critical: 101, + low: 3657, + cloudAccountId: '704479110758', + medium: 10931, + }, + }, + medium: 10931, }, ], topVulnerableResources: [ { - resource: { id: '01dfb5ad43724b411', name: 'dima-sanity-FQj' }, - vulnerabilityCount: 2030, + resource: { id: '0f7888a8ef7fdcbb4', name: 'kfir-June8-8-8-0-3mF' }, + vulnerabilityCount: 1544, cloudRegion: 'eu-west-1', }, { - resource: { id: '09aa0a3d51dae9820', name: 'dima-sanity-FQj' }, - vulnerabilityCount: 2030, + resource: { id: '042985d76403bab90', name: 'kfir3-june12-8-8-0-41B' }, + vulnerabilityCount: 1531, cloudRegion: 'eu-west-1', }, { - resource: { id: '042985d76403bab90', name: 'kfir3-june12-8-8-0-41B' }, - vulnerabilityCount: 1551, + resource: { id: '03dd0d62f0f8a11c6', name: 'dg-6-wl5' }, + vulnerabilityCount: 1508, cloudRegion: 'eu-west-1', }, { resource: { id: '0ba9cce1f0984b413', name: 'daily-env-8.7-vanilla' }, - vulnerabilityCount: 894, + vulnerabilityCount: 880, cloudRegion: 'eu-west-1', }, { resource: { id: '0a2dc4a316cdefd0a', name: 'TrackLiveEnvironment' }, - vulnerabilityCount: 679, + vulnerabilityCount: 588, cloudRegion: 'eu-west-1', }, { - resource: { - id: '0bcb81dda72d6d86a', - name: 'elastic-agent-instance-36c7f8d0-fd41-11ed-b093-02daf9c5eb3d', - }, - vulnerabilityCount: 383, + resource: { id: '089a865e3fe1fe29b', name: 'cloudbeat-tf-5jA-2' }, + vulnerabilityCount: 316, cloudRegion: 'eu-west-1', }, { - resource: { - id: '0413fc81a8f190eda', - name: 'elastic-agent-instance-19770570-e7b9-11ed-a680-02315045ab2d', - }, - vulnerabilityCount: 340, + resource: { id: '05b9d80b6e99ae260', name: 'cloudbeat-tf-5jA-2' }, + vulnerabilityCount: 312, cloudRegion: 'eu-west-1', }, { - resource: { - id: '006ea5239813449ee', - name: 'elastic-agent-instance-b32cdc60-091c-11ee-818e-06cae4cbdef5', - }, - vulnerabilityCount: 334, + resource: { id: '0e025869ddc1fbc7c', name: 'cloudbeat-tf-5jA-1' }, + vulnerabilityCount: 304, cloudRegion: 'eu-west-1', }, { - resource: { - id: '0f2dec779a9b6df40', - name: 'elastic-agent-instance-9fb90e20-fb1a-11ed-ac6c-02d320ffd109', - }, - vulnerabilityCount: 321, + resource: { id: '0407769a810e06483', name: 'cloudbeat-tf-5jA-1' }, + vulnerabilityCount: 302, cloudRegion: 'eu-west-1', }, { - resource: { id: '089a865e3fe1fe29b', name: 'cloudbeat-tf-5jA-2' }, - vulnerabilityCount: 314, + resource: { id: '0bb44b16a366c7ad9', name: 'cloudbeat-tf-5jA-2' }, + vulnerabilityCount: 302, cloudRegion: 'eu-west-1', }, ], @@ -126,19 +92,19 @@ export const mockCnvmDashboardData: CnvmDashboardData = { cve: 'CVE-2022-41723', cvss: { score: 7.5, version: '3.1' }, packageFixVersion: '0.7.0', - vulnerabilityCount: 384, + vulnerabilityCount: 380, }, { cve: 'CVE-2022-41717', cvss: { score: 5.300000190734863, version: '3.1' }, packageFixVersion: '0.4.0', - vulnerabilityCount: 335, + vulnerabilityCount: 331, }, { cve: 'CVE-2022-27664', cvss: { score: 7.5, version: '3.1' }, packageFixVersion: '0.0.0-20220906165146-f3363e06e74c', - vulnerabilityCount: 237, + vulnerabilityCount: 232, }, { cve: 'CVE-2021-37600', @@ -150,37 +116,37 @@ export const mockCnvmDashboardData: CnvmDashboardData = { cve: 'CVE-2023-2253', cvss: { score: 7.5, version: '3.1' }, packageFixVersion: '2.8.2-beta.1', - vulnerabilityCount: 157, - }, - { - cve: 'CVE-2023-2609', - cvss: { score: 7.800000190734863, version: '3.1' }, - packageFixVersion: '2:9.0.1592-1.amzn2.0.1', - vulnerabilityCount: 154, - }, - { - cve: 'CVE-2023-2610', - cvss: { score: 7.800000190734863, version: '3.1' }, - packageFixVersion: '2:9.0.1592-1.amzn2.0.1', - vulnerabilityCount: 154, + vulnerabilityCount: 160, }, { cve: 'CVE-2023-28840', cvss: { score: 7.5, version: '3.1' }, packageFixVersion: '23.0.3, 20.10.24', - vulnerabilityCount: 152, + vulnerabilityCount: 150, }, { cve: 'CVE-2023-28841', cvss: { score: 6.800000190734863, version: '3.1' }, packageFixVersion: '23.0.3, 20.10.24', - vulnerabilityCount: 152, + vulnerabilityCount: 150, }, { cve: 'CVE-2023-28842', cvss: { score: 6.800000190734863, version: '3.1' }, packageFixVersion: '23.0.3, 20.10.24', - vulnerabilityCount: 152, + vulnerabilityCount: 150, + }, + { + cve: 'CVE-2023-2609', + cvss: { score: 7.800000190734863, version: '3.1' }, + packageFixVersion: '2:9.0.1592-1.amzn2.0.1', + vulnerabilityCount: 140, + }, + { + cve: 'CVE-2023-2610', + cvss: { score: 7.800000190734863, version: '3.1' }, + packageFixVersion: '2:9.0.1592-1.amzn2.0.1', + vulnerabilityCount: 140, }, ], topVulnerabilities: [ @@ -188,27 +154,18 @@ export const mockCnvmDashboardData: CnvmDashboardData = { cve: 'CVE-2022-41723', packageFixVersion: '0.7.0', packageName: 'golang.org/x/net', - packageVersion: 'v0.0.0-20220809184613-07c6da5e1ced', + packageVersion: 'v0.0.0-20220127200216-cd36cc0744dd', severity: 'HIGH', - vulnerabilityCount: 384, + vulnerabilityCount: 380, cvss: { score: 7.5, version: '3.1' }, }, - { - cve: 'CVE-2022-41717', - packageFixVersion: '0.4.0', - packageName: 'golang.org/x/net', - packageVersion: 'v0.0.0-20220809184613-07c6da5e1ced', - severity: 'MEDIUM', - vulnerabilityCount: 335, - cvss: { score: 5.300000190734863, version: '3.1' }, - }, { cve: 'CVE-2020-8911', packageFixVersion: '', packageName: 'github.com/aws/aws-sdk-go', packageVersion: 'v1.38.60', severity: 'MEDIUM', - vulnerabilityCount: 334, + vulnerabilityCount: 340, cvss: { score: 5.599999904632568, version: '3.1' }, }, { @@ -217,35 +174,35 @@ export const mockCnvmDashboardData: CnvmDashboardData = { packageName: 'github.com/aws/aws-sdk-go', packageVersion: 'v1.38.60', severity: 'LOW', - vulnerabilityCount: 334, + vulnerabilityCount: 340, cvss: { score: 2.5, version: '3.1' }, }, + { + cve: 'CVE-2022-41717', + packageFixVersion: '0.4.0', + packageName: 'golang.org/x/net', + packageVersion: 'v0.0.0-20220127200216-cd36cc0744dd', + severity: 'MEDIUM', + vulnerabilityCount: 331, + cvss: { score: 5.300000190734863, version: '3.1' }, + }, { cve: 'CVE-2022-27664', packageFixVersion: '0.0.0-20220906165146-f3363e06e74c', packageName: 'golang.org/x/net', - packageVersion: 'v0.0.0-20220809184613-07c6da5e1ced', + packageVersion: 'v0.0.0-20220127200216-cd36cc0744dd', severity: 'HIGH', - vulnerabilityCount: 237, + vulnerabilityCount: 232, cvss: { score: 7.5, version: '3.1' }, }, { - cve: 'CVE-2023-2609', - packageFixVersion: '2:9.0.1592-1.amzn2.0.1', - packageName: 'vim-data', - packageVersion: '2:8.2.3995-1ubuntu2.7', - severity: 'HIGH', - vulnerabilityCount: 199, - cvss: { score: 7.800000190734863, version: '3.1' }, - }, - { - cve: 'CVE-2023-2610', - packageFixVersion: '2:9.0.1592-1.amzn2.0.1', - packageName: 'vim-data', - packageVersion: '2:8.2.3995-1ubuntu2.7', - severity: 'HIGH', - vulnerabilityCount: 199, - cvss: { score: 7.800000190734863, version: '3.1' }, + cve: 'CVE-2021-37600', + packageFixVersion: '2.30.2-2.amzn2.0.11', + packageName: 'libblkid', + packageVersion: '2.30.2-2.amzn2.0.10', + severity: 'LOW', + vulnerabilityCount: 168, + cvss: { score: 5.5, version: '3.1' }, }, { cve: 'CVE-2022-3219', @@ -253,26 +210,35 @@ export const mockCnvmDashboardData: CnvmDashboardData = { packageName: 'dirmngr', packageVersion: '2.2.27-3ubuntu2.1', severity: 'LOW', - vulnerabilityCount: 176, + vulnerabilityCount: 165, cvss: { score: 3.299999952316284, version: '3.1' }, }, - { - cve: 'CVE-2021-37600', - packageFixVersion: '2.30.2-2.amzn2.0.11', - packageName: 'libblkid', - packageVersion: '2.30.2-2.amzn2.0.10', - severity: 'LOW', - vulnerabilityCount: 168, - cvss: { score: 5.5, version: '3.1' }, - }, { cve: 'CVE-2023-2253', packageFixVersion: '2.8.2-beta.1', packageName: 'github.com/docker/distribution', packageVersion: 'v2.8.1+incompatible', severity: 'HIGH', - vulnerabilityCount: 157, + vulnerabilityCount: 160, cvss: { score: 7.5, version: '3.1' }, }, + { + cve: 'CVE-2023-28840', + packageFixVersion: '23.0.3, 20.10.24', + packageName: 'github.com/docker/docker', + packageVersion: 'v1.13.1', + severity: 'HIGH', + vulnerabilityCount: 150, + cvss: { score: 7.5, version: '3.1' }, + }, + { + cve: 'CVE-2023-28841', + packageFixVersion: '23.0.3, 20.10.24', + packageName: 'github.com/docker/docker', + packageVersion: 'v1.13.1', + severity: 'MEDIUM', + vulnerabilityCount: 150, + cvss: { score: 6.800000190734863, version: '3.1' }, + }, ], }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_trend_graph.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_trend_graph.tsx index 68538ea9210c7..c1fdccef0a086 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_trend_graph.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_trend_graph.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { Chart, Settings, @@ -15,11 +15,12 @@ import { niceTimeFormatByDay, PartialTheme, } from '@elastic/charts'; -import { EuiButton } from '@elastic/eui'; +import { EuiButton, EuiComboBox } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { truthy } from '../../../common/utils/helpers'; import { useNavigateVulnerabilities } from '../../common/hooks/use_navigate_findings'; -import { VulnSeverity } from '../../../common/types'; +import { VulnStatsTrend, VulnSeverity } from '../../../common/types'; import { useVulnerabilityDashboardApi } from '../../common/api/use_vulnerability_dashboard_api'; import { getSeverityStatusColor } from '../../common/utils/get_vulnerability_colors'; import { ChartPanel } from '../../components/chart_panel'; @@ -32,6 +33,8 @@ const stackAccessors: VulnSeverity[] = [ VULNERABILITIES_SEVERITY.LOW, ]; +const DEFAULT_ACCOUNT = 'all'; + const chartStyle = { width: '100%', height: 300 }; const theme: PartialTheme = { @@ -56,9 +59,102 @@ const ViewAllButton = () => { ); }; +const AccountDropDown = ({ + selectedAccount, + setSelectedAccount, + options = [], +}: { + selectedAccount: string; + setSelectedAccount: (value: string) => void; + options: Array<{ value: string; label: string }>; +}) => ( + o.value === selectedAccount)} + onChange={(selectedOption) => { + setSelectedAccount(selectedOption[0].value || DEFAULT_ACCOUNT); + }} + /> +); + +const getUniqueCloudAccountsOptions = ( + vulnTrends: VulnStatsTrend[] +): Array<{ value: string; label: string }> => { + const uniqueCloudAccounts: Array<{ label: string; value: string }> = []; + + vulnTrends.forEach((trendTimepoint) => { + const accountStatss = Object.values( + trendTimepoint.vulnerabilities_stats_by_cloud_account || {} + ); + + accountStatss.forEach((accountStats) => { + // Check if the entry already exists based on the cloudAccountId + const isDuplicate = uniqueCloudAccounts.find( + (account) => account.value === accountStats.cloudAccountId + ); + + // If no duplicate is found, add the account to the uniqueCloudAccounts array + if (!isDuplicate) { + uniqueCloudAccounts.push({ + label: accountStats.cloudAccountName, + value: accountStats.cloudAccountId, + }); + } + }); + }); + + uniqueCloudAccounts.unshift({ + label: i18n.translate( + 'xpack.csp.vulnerabilityDashboard.trendGraphChart.accountsDropDown.option.allTitle', + { defaultMessage: 'All' } + ), + value: DEFAULT_ACCOUNT, + }); + + return uniqueCloudAccounts; +}; + +const getTrendData = (vulnTrends: VulnStatsTrend[], selectedAccount: string) => { + if (selectedAccount === DEFAULT_ACCOUNT) { + const cleanAllTrend = vulnTrends.map((trendTimepoint) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { vulnerabilities_stats_by_cloud_account, policy_template, ...allAccountsTrendData } = + trendTimepoint; + return allAccountsTrendData; + }); + return cleanAllTrend; + } + + const accountTrend = vulnTrends + .map((trendTimepoint) => { + const selectedAccountStats = + trendTimepoint.vulnerabilities_stats_by_cloud_account?.[selectedAccount]; + + if (selectedAccountStats) + return { + '@timestamp': trendTimepoint['@timestamp'], + ...selectedAccountStats, + }; + }) + .filter(truthy); + + return accountTrend; +}; + export const VulnerabilityTrendGraph = () => { const getVulnerabilityDashboard = useVulnerabilityDashboardApi(); - const trendData = getVulnerabilityDashboard.data?.vulnTrends || []; + const vulnTrends = getVulnerabilityDashboard.data?.vulnTrends || []; + const [selectedAccount, setSelectedAccount] = useState(DEFAULT_ACCOUNT); + + const trendData = getTrendData(vulnTrends, selectedAccount); const bars: Array<{ yAccessors: string[]; @@ -98,7 +194,15 @@ export const VulnerabilityTrendGraph = () => { defaultMessage: 'Trend by severity', } )} - rightSideItems={[]} + rightSideItems={[ + , + , + ]} >
diff --git a/x-pack/plugins/cloud_security_posture/server/routes/vulnerabilities_dashboard/get_vulnerabilities_trend.ts b/x-pack/plugins/cloud_security_posture/server/routes/vulnerabilities_dashboard/get_vulnerabilities_trend.ts index 95f52676d09f8..a3e335734e74c 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/vulnerabilities_dashboard/get_vulnerabilities_trend.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/vulnerabilities_dashboard/get_vulnerabilities_trend.ts @@ -7,20 +7,20 @@ import { ElasticsearchClient } from '@kbn/core/server'; import { BENCHMARK_SCORE_INDEX_DEFAULT_NS } from '../../../common/constants'; -import { VulnScoreTrend } from '../../../common/types'; +import { VulnStatsTrend } from '../../../common/types'; interface LastDocBucket { key_as_string: string; last_doc: { hits: { hits: Array<{ - _source: VulnScoreTrend; + _source: VulnStatsTrend; }>; }; }; } -interface VulnScoreTrendResponse { +interface VulnStatsTrendResponse { vuln_severity_per_day: { buckets: LastDocBucket[]; }; @@ -78,17 +78,17 @@ export const getVulnTrendsQuery = () => ({ export const getVulnerabilitiesTrends = async ( esClient: ElasticsearchClient -): Promise => { - const vulnTrendsQueryResult = await esClient.search( +): Promise => { + const vulnTrendsQueryResult = await esClient.search( getVulnTrendsQuery() ); if (!vulnTrendsQueryResult.hits.hits) { throw new Error('Missing trend results from score index'); } - const vulnScoreTrendDocs = vulnTrendsQueryResult.aggregations?.vuln_severity_per_day.buckets?.map( + const vulnStatsTrendDocs = vulnTrendsQueryResult.aggregations?.vuln_severity_per_day.buckets?.map( (bucket) => bucket.last_doc.hits.hits[0]._source ); - return vulnScoreTrendDocs || []; + return vulnStatsTrendDocs || []; }; diff --git a/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts b/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts index 65b4b0d7572c5..756cf739711d4 100644 --- a/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts +++ b/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts @@ -178,7 +178,7 @@ const getScoreQuery = (): SearchRequest => ({ }, }); -const getVulnScoreQuery = (): SearchRequest => ({ +const getVulnStatsTrendQuery = (): SearchRequest => ({ index: LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, size: 0, query: { @@ -197,6 +197,37 @@ const getVulnScoreQuery = (): SearchRequest => ({ low: { filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.LOW } }, }, + vulnerabilities_stats_by_cloud_account: { + terms: { + field: 'cloud.account.id', + }, + aggs: { + cloud_account_id: { + terms: { + field: 'cloud.account.id', + size: 1, + }, + }, + cloud_account_name: { + terms: { + field: 'cloud.account.name', + size: 1, + }, + }, + critical: { + filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.CRITICAL } }, + }, + high: { + filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.HIGH } }, + }, + medium: { + filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.MEDIUM } }, + }, + low: { + filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.LOW } }, + }, + }, + }, }, }); @@ -234,20 +265,39 @@ const getFindingsScoresDocIndexingPromises = ( }); }); -const getVulnScoresDocIndexingPromises = ( +const getVulnStatsTrendDocIndexingPromises = ( esClient: ElasticsearchClient, - vulnScoreAggs?: VulnSeverityAggs + vulnStatsAggs?: VulnSeverityAggs ) => { - if (!vulnScoreAggs) return; + if (!vulnStatsAggs) return; + + const scoreByCloudAccount = Object.fromEntries( + vulnStatsAggs.vulnerabilities_stats_by_cloud_account.buckets.map((accountScore) => { + const cloudAccountId = accountScore.key; + + return [ + cloudAccountId, + { + cloudAccountId: accountScore.key, + cloudAccountName: accountScore.cloud_account_name.buckets[0].key, + critical: accountScore.critical.doc_count, + high: accountScore.high.doc_count, + medium: accountScore.medium.doc_count, + low: accountScore.low.doc_count, + }, + ]; + }) + ); return esClient.index({ index: BENCHMARK_SCORE_INDEX_DEFAULT_NS, document: { policy_template: VULN_MGMT_POLICY_TEMPLATE, - critical: vulnScoreAggs.critical.doc_count, - high: vulnScoreAggs.high.doc_count, - medium: vulnScoreAggs.medium.doc_count, - low: vulnScoreAggs.low.doc_count, + critical: vulnStatsAggs.critical.doc_count, + high: vulnStatsAggs.high.doc_count, + medium: vulnStatsAggs.medium.doc_count, + low: vulnStatsAggs.low.doc_count, + vulnerabilities_stats_by_cloud_account: scoreByCloudAccount, }, }); }; @@ -262,11 +312,11 @@ export const aggregateLatestFindings = async ( const scoreIndexQueryResult = await esClient.search( getScoreQuery() ); - const vulnScoreIndexQueryResult = await esClient.search( - getVulnScoreQuery() + const vulnStatsTrendIndexQueryResult = await esClient.search( + getVulnStatsTrendQuery() ); - if (!scoreIndexQueryResult.aggregations && !vulnScoreIndexQueryResult.aggregations) { + if (!scoreIndexQueryResult.aggregations && !vulnStatsTrendIndexQueryResult.aggregations) { logger.warn(`No data found in latest findings index`); return 'warning'; } @@ -288,16 +338,16 @@ export const aggregateLatestFindings = async ( scoresByPolicyTemplatesBuckets ); - const vulnScoresDocIndexingPromises = getVulnScoresDocIndexingPromises( + const vulnStatsTrendDocIndexingPromises = getVulnStatsTrendDocIndexingPromises( esClient, - vulnScoreIndexQueryResult.aggregations + vulnStatsTrendIndexQueryResult.aggregations ); const startIndexTime = performance.now(); // executing indexing commands await Promise.all( - [...findingsScoresDocIndexingPromises, vulnScoresDocIndexingPromises].filter(Boolean) + [...findingsScoresDocIndexingPromises, vulnStatsTrendDocIndexingPromises].filter(Boolean) ); const totalIndexTime = Number(performance.now() - startIndexTime).toFixed(2); diff --git a/x-pack/plugins/cloud_security_posture/server/tasks/types.ts b/x-pack/plugins/cloud_security_posture/server/tasks/types.ts index abf5a3b2ac4b7..c67f682c7c016 100644 --- a/x-pack/plugins/cloud_security_posture/server/tasks/types.ts +++ b/x-pack/plugins/cloud_security_posture/server/tasks/types.ts @@ -38,6 +38,28 @@ export interface VulnSeverityAggs { low: { doc_count: number; }; + vulnerabilities_stats_by_cloud_account: { + buckets: Array<{ + key: string; // cloud account id + critical: { + doc_count: number; + }; + high: { + doc_count: number; + }; + medium: { + doc_count: number; + }; + low: { + doc_count: number; + }; + cloud_account_name: { + buckets: Array<{ + key: string; // cloud account name + }>; + }; + }>; + }; } export type TaskHealthStatus = 'ok' | 'warning' | 'error'; From c5dace2e3b1c6e17050a2e5fc2f39b76d0cbb79b Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Wed, 21 Jun 2023 17:33:07 +0200 Subject: [PATCH 26/31] [Observability] Rename coPilot settings to aiAssistant (#160144) --- test/plugin_functional/test_suites/core_plugins/rendering.ts | 2 +- .../public/components/co_pilot_prompt/co_pilot_prompt.tsx | 2 +- .../observability/public/pages/overview/overview.stories.tsx | 2 +- x-pack/plugins/observability/public/plugin.ts | 4 ++-- .../public/utils/kibana_react.storybook_decorator.tsx | 2 +- x-pack/plugins/observability/public/utils/test_helper.tsx | 2 +- x-pack/plugins/observability/server/index.ts | 4 ++-- x-pack/plugins/observability/server/plugin.ts | 4 +++- 8 files changed, 12 insertions(+), 10 deletions(-) diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 1e8dc4eb899d5..ca79d4de25172 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -264,7 +264,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.upgrade_assistant.featureSet.mlSnapshots (boolean)', 'xpack.upgrade_assistant.featureSet.reindexCorrectiveActions (boolean)', 'xpack.upgrade_assistant.ui.enabled (boolean)', - 'xpack.observability.coPilot.enabled (boolean)', + 'xpack.observability.aiAssistant.enabled (boolean)', 'xpack.observability.unsafe.alertDetails.metrics.enabled (boolean)', 'xpack.observability.unsafe.alertDetails.logs.enabled (boolean)', 'xpack.observability.unsafe.alertDetails.uptime.enabled (boolean)', diff --git a/x-pack/plugins/observability/public/components/co_pilot_prompt/co_pilot_prompt.tsx b/x-pack/plugins/observability/public/components/co_pilot_prompt/co_pilot_prompt.tsx index 4df93e45db500..9f762b20e9146 100644 --- a/x-pack/plugins/observability/public/components/co_pilot_prompt/co_pilot_prompt.tsx +++ b/x-pack/plugins/observability/public/components/co_pilot_prompt/co_pilot_prompt.tsx @@ -129,7 +129,7 @@ export default function CoPilotPrompt({ } const tooltipContent = i18n.translate('xpack.observability.coPilotPrompt.askCoPilot', { - defaultMessage: 'Ask Observability Co-Pilot for assistence', + defaultMessage: 'Ask Observability AI Assistent for help', }); return ( diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index 4be72ada7055a..64d640ef93475 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -88,7 +88,7 @@ const withCore = makeDecorator({ thresholdRule: { enabled: false }, }, compositeSlo: { enabled: false }, - coPilot: { + aiAssistant: { enabled: false, }, }; diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index d87a95e0f7284..1fb04d947bef2 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -83,7 +83,7 @@ export interface ConfigSchema { }; }; compositeSlo: { enabled: boolean }; - coPilot?: { + aiAssistant?: { enabled?: boolean; }; } @@ -330,7 +330,7 @@ export class Plugin ); this.coPilotService = createCoPilotService({ - enabled: !!config.coPilot?.enabled, + enabled: !!config.aiAssistant?.enabled, http: coreSetup.http, }); diff --git a/x-pack/plugins/observability/public/utils/kibana_react.storybook_decorator.tsx b/x-pack/plugins/observability/public/utils/kibana_react.storybook_decorator.tsx index 67ca140fdcc57..98b4d32dd7c6c 100644 --- a/x-pack/plugins/observability/public/utils/kibana_react.storybook_decorator.tsx +++ b/x-pack/plugins/observability/public/utils/kibana_react.storybook_decorator.tsx @@ -35,7 +35,7 @@ export function KibanaReactStorybookDecorator(Story: ComponentType) { thresholdRule: { enabled: false }, }, compositeSlo: { enabled: false }, - coPilot: { + aiAssistant: { enabled: false, }, }; diff --git a/x-pack/plugins/observability/public/utils/test_helper.tsx b/x-pack/plugins/observability/public/utils/test_helper.tsx index c60fa3e37cd55..ac0a79975af75 100644 --- a/x-pack/plugins/observability/public/utils/test_helper.tsx +++ b/x-pack/plugins/observability/public/utils/test_helper.tsx @@ -39,7 +39,7 @@ const defaultConfig: ConfigSchema = { thresholdRule: { enabled: false }, }, compositeSlo: { enabled: false }, - coPilot: { + aiAssistant: { enabled: false, }, }; diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts index 1bdacac1aee58..3ea386dbb1d99 100644 --- a/x-pack/plugins/observability/server/index.ts +++ b/x-pack/plugins/observability/server/index.ts @@ -51,7 +51,7 @@ const configSchema = schema.object({ groupByPageSize: schema.number({ defaultValue: 10_000 }), }), enabled: schema.boolean({ defaultValue: true }), - coPilot: schema.maybe(observabilityCoPilotConfig), + aiAssistant: schema.maybe(observabilityCoPilotConfig), compositeSlo: schema.object({ enabled: schema.boolean({ defaultValue: false }), }), @@ -60,7 +60,7 @@ const configSchema = schema.object({ export const config: PluginConfigDescriptor = { exposeToBrowser: { unsafe: true, - coPilot: { + aiAssistant: { enabled: true, }, }, diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts index c94682528a8f6..ab74a511b265f 100644 --- a/x-pack/plugins/observability/server/plugin.ts +++ b/x-pack/plugins/observability/server/plugin.ts @@ -242,7 +242,9 @@ export class ObservabilityPlugin implements Plugin { ); registerSloUsageCollector(plugins.usageCollection); - const openAIService = config.coPilot?.enabled ? new OpenAIService(config.coPilot) : undefined; + const openAIService = config.aiAssistant?.enabled + ? new OpenAIService(config.aiAssistant) + : undefined; core.getStartServices().then(([coreStart, pluginStart]) => { registerRoutes({ From 22fc60578953fdc418dead6715b16831028565d8 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 21 Jun 2023 09:35:22 -0600 Subject: [PATCH 27/31] [Security solution] Fix broken rule preview when `tiebreakerField` is empty string (#160082) --- .../timeline/search_super_select/index.tsx | 56 ++++++++++--------- .../eql/build_eql_search_request.test.ts | 39 ++++++++++++- .../eql/build_eql_search_request.ts | 7 ++- 3 files changed, 74 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx index 033797216b63a..0db382886e794 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx @@ -16,32 +16,35 @@ import * as i18n from '../translations'; import type { TimelineTypeLiteral } from '../../../../../common/types/timeline/api'; import { TimelineType } from '../../../../../common/types/timeline/api'; -const StyledEuiFieldText = styled(EuiFieldText)` - padding-left: 12px; - padding-right: 40px; +const StyledEuiInputPopover = styled(EuiInputPopover)` + .rightArrowIcon { + .euiFieldText { + padding-left: 12px; + padding-right: 40px; - &[readonly] { - cursor: pointer; - background-size: 0 100%; - background-repeat: no-repeat; + &[readonly] { + cursor: pointer; + background-size: 0 100%; + background-repeat: no-repeat; - // To match EuiFieldText focus state - &:focus { - background-color: ${({ theme }) => theme.eui.euiFormBackgroundColor}; - background-image: linear-gradient( - to top, - ${({ theme }) => theme.eui.euiFocusRingColor}, - ${({ theme }) => theme.eui.euiFocusRingColor} 2px, - transparent 2px, - transparent 100% - ); - background-size: 100% 100%; + // To match EuiFieldText focus state + &:focus { + background-color: ${({ theme }) => theme.eui.euiFormBackgroundColor}; + background-image: linear-gradient( + to top, + ${({ theme }) => theme.eui.euiFocusRingColor}, + ${({ theme }) => theme.eui.euiFocusRingColor} 2px, + transparent 2px, + transparent 100% + ); + background-size: 100% 100%; + } + } + } + .euiFormControlLayoutIcons { + left: unset; + right: 12px; } - } - - & + .euiFormControlLayoutIcons { - left: unset; - right: 12px; } `; @@ -87,7 +90,7 @@ const SearchTimelineSuperSelectComponent: React.FC ( - - + ); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.test.ts index 571684f1fdcee..d9232b99a49c8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.test.ts @@ -318,7 +318,6 @@ describe('buildEqlSearchRequest', () => { "query": "process where true", "runtime_mappings": undefined, "size": 100, - "tiebreaker_field": undefined, "timestamp_field": undefined, }, "index": Array [ @@ -429,4 +428,42 @@ describe('buildEqlSearchRequest', () => { }, }); }); + + describe('handles the tiebreaker field', () => { + test('should pass a tiebreaker field with a valid value', async () => { + const request = buildEqlSearchRequest({ + query: 'process where true', + index: ['testindex1', 'testindex2'], + from: 'now-5m', + to: 'now', + size: 100, + filters: undefined, + primaryTimestamp: '@timestamp', + secondaryTimestamp: undefined, + runtimeMappings: undefined, + tiebreakerField: 'host.name', + eventCategoryOverride: undefined, + exceptionFilter: undefined, + }); + expect(request?.body?.tiebreaker_field).toEqual(`host.name`); + }); + + test('should not pass a tiebreaker field with a valid value', async () => { + const request = buildEqlSearchRequest({ + query: 'process where true', + index: ['testindex1', 'testindex2'], + from: 'now-5m', + to: 'now', + size: 100, + filters: undefined, + primaryTimestamp: '@timestamp', + secondaryTimestamp: undefined, + runtimeMappings: undefined, + tiebreakerField: '', + eventCategoryOverride: undefined, + exceptionFilter: undefined, + }); + expect(request?.body?.tiebreaker_field).toEqual(undefined); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.ts index 3d8910a8e9bab..317cb57402b8b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.ts @@ -7,6 +7,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { Filter } from '@kbn/es-query'; +import { isEmpty } from 'lodash/fp'; import type { RuleFilterArray, TimestampOverride, @@ -89,7 +90,11 @@ export const buildEqlSearchRequest = ({ runtime_mappings: runtimeMappings, timestamp_field: timestampField, event_category_field: eventCategoryOverride, - tiebreaker_field: tiebreakerField, + ...(!isEmpty(tiebreakerField) + ? { + tiebreaker_field: tiebreakerField, + } + : {}), fields, }, }; From 3efedc815aaac554ed83bbaeec9b61ed1a539f11 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 21 Jun 2023 16:37:39 +0100 Subject: [PATCH 28/31] skip flaky suite (#160091) --- .../sections/alerts_table/alerts_table_state.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx index 557e0cf04b0f9..ccf31e86f4d1a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx @@ -707,7 +707,8 @@ describe('AlertsTableState', () => { }); }); - describe('field browser', () => { + // FLAKY: https://github.com/elastic/kibana/issues/160091 + describe.skip('field browser', () => { const browserFields: BrowserFields = { kibana: { fields: { From 8a5f0015a9db0b55fcd8d252c68f4a4ec8a5a11b Mon Sep 17 00:00:00 2001 From: Saarika Bhasi <55930906+saarikabhasi@users.noreply.github.com> Date: Wed, 21 Jun 2023 11:41:30 -0400 Subject: [PATCH 29/31] [Enterprise Search] Search Application: Reword Safe search api to Search api (#160147) ## Summary Based on the [discussion](https://elastic.slack.com/archives/C02U50QNEAG/p1687302497582719), rewording `Safe Search API` to `Search API` ### Screen Shot Screenshot 2023-06-21 at 10 30 40 AM 1 --- .../engine/engine_connect/engine_connect.tsx | 14 ++-- .../engine_connect/search_application_api.tsx | 72 +++++++++---------- .../engine_search_preview.tsx | 2 +- .../components/engine/engine_view.tsx | 2 +- .../applications/applications/routes.ts | 2 +- 5 files changed, 46 insertions(+), 46 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/engine_connect.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/engine_connect.tsx index 05a1f23031c20..8d1c896f6ac54 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/engine_connect.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/engine_connect.tsx @@ -36,9 +36,9 @@ const pageTitle = i18n.translate( } ); const SAFE_SEARCH_API_TAB_TITLE = i18n.translate( - 'xpack.enterpriseSearch.content.searchApplications.connect.safeSearchAPITabTitle', + 'xpack.enterpriseSearch.content.searchApplications.connect.searchAPITabTitle', { - defaultMessage: 'Safe Search API', + defaultMessage: 'Search API', } ); const DOCUMENTATION_TAB_TITLE = i18n.translate( @@ -50,7 +50,7 @@ const DOCUMENTATION_TAB_TITLE = i18n.translate( const ConnectTabs: string[] = Object.values(SearchApplicationConnectTabs); const getTabBreadCrumb = (tabId: string) => { switch (tabId) { - case SearchApplicationConnectTabs.SAFESEARCHAPI: + case SearchApplicationConnectTabs.SEARCHAPI: return SAFE_SEARCH_API_TAB_TITLE; case SearchApplicationConnectTabs.DOCUMENTATION: return DOCUMENTATION_TAB_TITLE; @@ -61,7 +61,7 @@ const getTabBreadCrumb = (tabId: string) => { export const EngineConnect: React.FC = () => { const { engineName, isLoadingEngine, hasSchemaConflicts } = useValues(EngineViewLogic); - const { connectTabId = SearchApplicationConnectTabs.SAFESEARCHAPI } = useParams<{ + const { connectTabId = SearchApplicationConnectTabs.SEARCHAPI } = useParams<{ connectTabId?: string; }>(); @@ -106,9 +106,9 @@ export const EngineConnect: React.FC = () => { rightSideItems: [], tabs: [ { - isSelected: connectTabId === SearchApplicationConnectTabs.SAFESEARCHAPI, + isSelected: connectTabId === SearchApplicationConnectTabs.SEARCHAPI, label: SAFE_SEARCH_API_TAB_TITLE, - onClick: onTabClick(SearchApplicationConnectTabs.SAFESEARCHAPI), + onClick: onTabClick(SearchApplicationConnectTabs.SEARCHAPI), }, { isSelected: connectTabId === SearchApplicationConnectTabs.DOCUMENTATION, @@ -120,7 +120,7 @@ export const EngineConnect: React.FC = () => { engineName={engineName} hasSchemaConflicts={hasSchemaConflicts} > - {connectTabId === SearchApplicationConnectTabs.SAFESEARCHAPI && } + {connectTabId === SearchApplicationConnectTabs.SEARCHAPI && } {connectTabId === SearchApplicationConnectTabs.DOCUMENTATION && ( )} diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/search_application_api.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/search_application_api.tsx index 216fe729d2939..d615cd12c72e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/search_application_api.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/search_application_api.tsx @@ -52,13 +52,13 @@ export const SearchApplicationAPI = () => {

@@ -80,13 +80,13 @@ export const SearchApplicationAPI = () => {

@@ -98,7 +98,7 @@ export const SearchApplicationAPI = () => { ), title: i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step1.setUpSearchtemplate.title', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step1.setUpSearchtemplate.title', { defaultMessage: 'Set up your search template', } @@ -110,13 +110,13 @@ export const SearchApplicationAPI = () => {

@@ -132,10 +132,10 @@ export const SearchApplicationAPI = () => { iconSide="left" iconType="plusInCircleFilled" onClick={openGenerateModal} - data-telemetry-id="entSearchApplications-safeSearchApi-step2-createApiKeyButton" + data-telemetry-id="entSearchApplications-searchApi-step2-createApiKeyButton" > {i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step2.createAPIKeyButton', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step2.createAPIKeyButton', { defaultMessage: 'Create API Key', } @@ -146,7 +146,7 @@ export const SearchApplicationAPI = () => { KibanaLogic.values.navigateToUrl('/app/management/security/api_keys', { shouldNotCreateHref: true, @@ -154,7 +154,7 @@ export const SearchApplicationAPI = () => { } > {i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step2.viewKeysButton', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step2.viewKeysButton', { defaultMessage: 'View Keys', } @@ -165,7 +165,7 @@ export const SearchApplicationAPI = () => { ), title: i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step2.title', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step2.title', { defaultMessage: 'Generate and save API key', } @@ -177,7 +177,7 @@ export const SearchApplicationAPI = () => {

{i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step3.copyEndpointDescription', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step3.copyEndpointDescription', { defaultMessage: "Here's the URL for your endpoint:", } @@ -197,16 +197,16 @@ export const SearchApplicationAPI = () => { ), title: i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step3.title', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step3.title', { - defaultMessage: 'Copy your Safe Search endpoint', + defaultMessage: 'Copy your Search endpoint', } ), }, { children: , title: i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step4.title', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step4.title', { defaultMessage: 'Learn how to call your endpoint', } @@ -226,25 +226,25 @@ export const SearchApplicationAPI = () => { iconType="iInCircle" title={ } > {i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.safeSearchCallout.body.safeSearchDocLink', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.searchApiCallout.body.searchApiDocLink', { - defaultMessage: 'Safe Search API', + defaultMessage: 'Search API', } )} @@ -256,7 +256,7 @@ export const SearchApplicationAPI = () => { data-telemetry-id="entSearchApplications-searchTemplate-documentation-viewDocumentaion" > {i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.safeSearchCallout.body.searchTemplateDocLink', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.searchApiCallout.body.searchTemplateDocLink', { defaultMessage: 'search template', } @@ -267,20 +267,20 @@ export const SearchApplicationAPI = () => { /> {i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.safeSearchCallout.body.safeSearchDocumentationLink', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.searchApiCallout.body.searchApiDocumentationLink', { - defaultMessage: 'Learn more about the Safe Search API', + defaultMessage: 'Learn more about the Search API', } )} diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx index cde84312d81f7..bfec3086c5e87 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx @@ -289,7 +289,7 @@ const ConfigurationPopover: React.FC = ({ onClick={() => navigateToUrl( generateEncodedPath(SEARCH_APPLICATION_CONNECT_PATH, { - connectTabId: SearchApplicationConnectTabs.SAFESEARCHAPI, + connectTabId: SearchApplicationConnectTabs.SEARCHAPI, engineName, }) ) diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_view.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_view.tsx index 419b8a9d21e82..6b66c94f5ecf6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_view.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_view.tsx @@ -97,7 +97,7 @@ export const EngineView: React.FC = () => { Date: Wed, 21 Jun 2023 16:47:51 +0100 Subject: [PATCH 30/31] skip failing es promotin suite (#160164) --- test/functional/apps/visualize/group2/_metric_chart.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/visualize/group2/_metric_chart.ts b/test/functional/apps/visualize/group2/_metric_chart.ts index 16faf916acba0..b70bde00f9d13 100644 --- a/test/functional/apps/visualize/group2/_metric_chart.ts +++ b/test/functional/apps/visualize/group2/_metric_chart.ts @@ -17,7 +17,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const inspector = getService('inspector'); const PageObjects = getPageObjects(['visualize', 'visEditor', 'visChart', 'timePicker']); - describe('metric chart', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/160164 + describe.skip('metric chart', function () { before(async function () { await PageObjects.visualize.initTests(); log.debug('navigateToApp visualize'); From be71e339643ec2950907b141131a267498734069 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 21 Jun 2023 16:51:57 +0100 Subject: [PATCH 31/31] skip failing es promotin suite (#160166) --- .../apps/integrations/artifact_entries_list.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_endpoint/apps/integrations/artifact_entries_list.ts b/x-pack/test/security_solution_endpoint/apps/integrations/artifact_entries_list.ts index b7f01d9b5d63d..971ba3288366d 100644 --- a/x-pack/test/security_solution_endpoint/apps/integrations/artifact_entries_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/integrations/artifact_entries_list.ts @@ -51,7 +51,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { .set('kbn-xsrf', 'true'); }; - describe('For each artifact list under management', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/160166 + describe.skip('For each artifact list under management', function () { this.timeout(150_000); let indexedData: IndexedHostsAndAlertsResponse; let policyInfo: PolicyTestResourceInfo;