diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index 1c026f571db60..1c136cb559ce5 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -358,6 +358,7 @@ enabled: - x-pack/test/saved_object_api_integration/spaces_only/config.ts - x-pack/test/saved_object_tagging/api_integration/security_and_spaces/config.ts - x-pack/test/saved_object_tagging/api_integration/tagging_api/config.ts + - x-pack/test/saved_object_tagging/api_integration/tagging_usage_collection/config.ts - x-pack/test/saved_object_tagging/functional/config.ts - x-pack/test/saved_objects_field_count/config.ts - x-pack/test/search_sessions_integration/config.ts diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index 086098cee6790..133004f468948 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -154,16 +154,15 @@ steps: - exit_status: '-1' limit: 3 -# TODO: Enable in #166813 after fixing types -# - command: .buildkite/scripts/steps/check_types.sh -# label: 'Check Types' -# agents: -# queue: n2-16-spot -# timeout_in_minutes: 60 -# retry: -# automatic: -# - exit_status: '-1' -# limit: 3 + - command: .buildkite/scripts/steps/check_types.sh + label: 'Check Types' + agents: + queue: n2-16-spot + timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/storybooks/build_and_upload.sh label: 'Build Storybooks' diff --git a/.buildkite/pipelines/pull_request/base.yml b/.buildkite/pipelines/pull_request/base.yml index 9c2527fcdd413..158c22c0bb0c5 100644 --- a/.buildkite/pipelines/pull_request/base.yml +++ b/.buildkite/pipelines/pull_request/base.yml @@ -136,13 +136,12 @@ steps: - exit_status: '-1' limit: 3 -# TODO: Enable in #166813 after fixing types -# - command: .buildkite/scripts/steps/check_types.sh -# label: 'Check Types' -# agents: -# queue: n2-16-spot -# timeout_in_minutes: 60 -# retry: -# automatic: -# - exit_status: '-1' -# limit: 3 + - command: .buildkite/scripts/steps/check_types.sh + label: 'Check Types' + agents: + queue: n2-16-spot + timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 diff --git a/.buildkite/pipelines/pull_request/type_check_selective.yml b/.buildkite/pipelines/pull_request/type_check_selective.yml deleted file mode 100644 index 7d01f128aac3c..0000000000000 --- a/.buildkite/pipelines/pull_request/type_check_selective.yml +++ /dev/null @@ -1,10 +0,0 @@ -steps: - - command: .buildkite/scripts/steps/check_types_commits.sh - label: 'Check Types Commit Diff' - agents: - queue: n2-16-spot - timeout_in_minutes: 60 - retry: - automatic: - - exit_status: '-1' - limit: 3 diff --git a/.buildkite/pipelines/quality-gates/pipeline.tests-qa.yaml b/.buildkite/pipelines/quality-gates/pipeline.tests-qa.yaml index 5321f24ae6e3b..962da8da4d86e 100644 --- a/.buildkite/pipelines/quality-gates/pipeline.tests-qa.yaml +++ b/.buildkite/pipelines/quality-gates/pipeline.tests-qa.yaml @@ -21,6 +21,8 @@ steps: build: env: ENVIRONMENT: ${ENVIRONMENT} + EC_ENV: qa + EC_REGION: aws-eu-west-1 message: "${BUILDKITE_MESSAGE} (triggered by pipeline.tests-qa.yaml)" - group: ":female-detective: Security Solution Tests" diff --git a/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml b/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml index 6a5edc3a97073..42fa2b34ea84f 100644 --- a/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml +++ b/.buildkite/pipelines/quality-gates/pipeline.tests-staging.yaml @@ -21,6 +21,16 @@ steps: NAME_PREFIX: ci_test_kibana-promotion_ message: "${BUILDKITE_MESSAGE} (triggered by pipeline.tests-staging.yaml)" + - label: ":pipeline::kibana::seedling: Trigger Kibana Serverless Tests for ${ENVIRONMENT}" + trigger: appex-qa-serverless-kibana-ftr-tests # https://buildkite.com/elastic/appex-qa-serverless-kibana-ftr-tests + soft_fail: true # Remove this before release or when tests stabilize + build: + env: + ENVIRONMENT: ${ENVIRONMENT} + EC_ENV: staging + EC_REGION: aws-us-east-1 + message: "${BUILDKITE_MESSAGE} (triggered by pipeline.tests-staging.yaml)" + - wait: ~ - label: ":judge::seedling: Trigger Manual Tests Phase" diff --git a/.buildkite/scripts/pipelines/pull_request/pipeline.ts b/.buildkite/scripts/pipelines/pull_request/pipeline.ts index 37bd89f2a75a7..80d1312af6e64 100644 --- a/.buildkite/scripts/pipelines/pull_request/pipeline.ts +++ b/.buildkite/scripts/pipelines/pull_request/pipeline.ts @@ -59,12 +59,6 @@ const uploadPipeline = (pipelineContent: string | object) => { pipeline.push(getPipeline('.buildkite/pipelines/pull_request/kbn_handlebars.yml')); } - if (GITHUB_PR_LABELS.includes('ci:hard-typecheck')) { - pipeline.push(getPipeline('.buildkite/pipelines/pull_request/type_check.yml')); - } else { - pipeline.push(getPipeline('.buildkite/pipelines/pull_request/type_check_selective.yml')); - } - if ( (await doAnyChangesMatch([ /^src\/plugins\/controls/, diff --git a/.buildkite/scripts/steps/check_types_commits.sh b/.buildkite/scripts/steps/check_types_commits.sh index 2fe1af46825fb..d34c4dae5ffa9 100755 --- a/.buildkite/scripts/steps/check_types_commits.sh +++ b/.buildkite/scripts/steps/check_types_commits.sh @@ -2,6 +2,10 @@ set -euo pipefail +# This script will collect typescript projects and run typecheck on projects between the given 2 parameters +# Could be used for selective typechecking on projects that might be affected for a given PR. +# (The accuracy for finding related projects is not a 100%) + if [[ "${CI-}" == "true" ]]; then .buildkite/scripts/bootstrap.sh diff --git a/.buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh b/.buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh index c6bf1738fe144..c0a5a0d4e8407 100755 --- a/.buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh +++ b/.buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh @@ -24,11 +24,37 @@ echo "--- Promoting ${SOURCE_IMAGE_OR_TAG} to ':latest-verified'" echo "Re-tagging $SOURCE_IMAGE -> $TARGET_IMAGE" echo "$KIBANA_DOCKER_PASSWORD" | docker login -u "$KIBANA_DOCKER_USERNAME" --password-stdin docker.elastic.co -docker pull "$SOURCE_IMAGE" -docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE" -docker push "$TARGET_IMAGE" -ORIG_IMG_DATA=$(docker inspect "$SOURCE_IMAGE") +docker manifest inspect "$SOURCE_IMAGE" | tee manifests.json + +ARM_64_DIGEST=$(jq -r '.manifests[] | select(.platform.architecture == "arm64") | .digest' manifests.json) +AMD_64_DIGEST=$(jq -r '.manifests[] | select(.platform.architecture == "amd64") | .digest' manifests.json) + +echo docker pull --platform linux/arm64 "$SOURCE_IMAGE@$ARM_64_DIGEST" +docker pull --platform linux/arm64 "$SOURCE_IMAGE@$ARM_64_DIGEST" +echo linux/arm64 image pulled, with digest: $ARM_64_DIGEST + +echo docker pull --platform linux/amd64 "$SOURCE_IMAGE@$AMD_64_DIGEST" +docker pull --platform linux/amd64 "$SOURCE_IMAGE@$AMD_64_DIGEST" +echo linux/amd64 image pulled, with digest: $AMD_64_DIGEST + +docker tag "$SOURCE_IMAGE@$ARM_64_DIGEST" "$TARGET_IMAGE-arm64" +docker tag "$SOURCE_IMAGE@$AMD_64_DIGEST" "$TARGET_IMAGE-amd64" + +docker push "$TARGET_IMAGE-arm64" +docker push "$TARGET_IMAGE-amd64" + +docker manifest rm "$TARGET_IMAGE" || echo "Nothing to delete" + +docker manifest create "$TARGET_IMAGE" \ +--amend "$TARGET_IMAGE-arm64" \ +--amend "$TARGET_IMAGE-amd64" + +docker manifest push "$TARGET_IMAGE" + +docker manifest inspect "$TARGET_IMAGE" + +ORIG_IMG_DATA=$(docker inspect "$SOURCE_IMAGE@$ARM_64_DIGEST") ELASTIC_COMMIT_HASH=$(echo $ORIG_IMG_DATA | jq -r '.[].Config.Labels["org.opencontainers.image.revision"]') docker logout docker.elastic.co @@ -37,7 +63,7 @@ echo "Image push to $TARGET_IMAGE successful." echo "Promotion successful! Henceforth, thou shall be named Sir $TARGET_IMAGE" MANIFEST_UPLOAD_PATH="Skipped" -if [[ "$UPLOAD_MANIFEST" =~ ^(1|true)$ && "$SOURCE_IMAGE_OR_TAG" =~ ^git-[0-9a-fA-F]{12}$ ]]; then +if [[ "${UPLOAD_MANIFEST:-}" =~ ^(1|true)$ && "$SOURCE_IMAGE_OR_TAG" =~ ^git-[0-9a-fA-F]{12}$ ]]; then echo "--- Uploading latest-verified manifest to GCS" cat << EOT >> $MANIFEST_FILE_NAME { @@ -58,10 +84,12 @@ EOT gsutil acl ch -u AllUsers:R "gs://$ES_SERVERLESS_BUCKET/$MANIFEST_FILE_NAME" MANIFEST_UPLOAD_PATH="$MANIFEST_FILE_NAME" -elif [[ "$UPLOAD_MANIFEST" =~ ^(1|true)$ ]]; then +elif [[ "${UPLOAD_MANIFEST:-}" =~ ^(1|true)$ ]]; then echo "--- Skipping upload of latest-verified manifest to GCS, ES Serverless build tag is not pointing to a hash" elif [[ "$SOURCE_IMAGE_OR_TAG" =~ ^git-[0-9a-fA-F]{12}$ ]]; then echo "--- Skipping upload of latest-verified manifest to GCS, flag was not provided" +else + echo "--- Skipping upload of latest-verified manifest to GCS, no flag and hash provided" fi echo "--- Annotating build with info" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cc7a42092c9ea..2dd00375c68d4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -485,6 +485,7 @@ packages/kbn-managed-vscode-config @elastic/kibana-operations packages/kbn-managed-vscode-config-cli @elastic/kibana-operations packages/kbn-management/cards_navigation @elastic/platform-deployment-management src/plugins/management @elastic/platform-deployment-management +packages/kbn-management/settings/components/field_category @elastic/platform-deployment-management packages/kbn-management/settings/components/field_input @elastic/platform-deployment-management packages/kbn-management/settings/components/field_row @elastic/platform-deployment-management packages/kbn-management/settings/components/form @elastic/platform-deployment-management diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index f2acaee21eb90..40a5135d25a5b 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -455,8 +455,7 @@ The plugin exposes the static DefaultEditorController class to consume. |{kib-repo}blob/{branch}/x-pack/plugins/asset_manager/README.md[assetManager] -|This plugin provides access to the asset data stored in assets-* indices, primarily -for inventory and topology purposes. +|This plugin provides access to observed asset data, such as information about hosts, pods, containers, services, and more. |{kib-repo}blob/{branch}/x-pack/plugins/banners/README.md[banners] diff --git a/package.json b/package.json index f6834e5f90587..6ea5ef3486efe 100644 --- a/package.json +++ b/package.json @@ -507,6 +507,7 @@ "@kbn/logstash-plugin": "link:x-pack/plugins/logstash", "@kbn/management-cards-navigation": "link:packages/kbn-management/cards_navigation", "@kbn/management-plugin": "link:src/plugins/management", + "@kbn/management-settings-components-field-category": "link:packages/kbn-management/settings/components/field_category", "@kbn/management-settings-components-field-input": "link:packages/kbn-management/settings/components/field_input", "@kbn/management-settings-components-field-row": "link:packages/kbn-management/settings/components/field_row", "@kbn/management-settings-components-form": "link:packages/kbn-management/settings/components/form", diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/loading_indicator.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/loading_indicator.tsx index 8e3cae33ceb84..22e9b5dd9276d 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/loading_indicator.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/loading_indicator.tsx @@ -19,6 +19,8 @@ export interface LoadingIndicatorProps { loadingCount$: ReturnType; showAsBar?: boolean; customLogo?: string; + maxAmount?: number; + valueAmount?: string | number; } export class LoadingIndicator extends React.Component { @@ -62,8 +64,6 @@ export class LoadingIndicator extends React.Component; + +export const Categories: Story = (params) => { + const { onClearQuery, isSavingEnabled, onFieldChange, unsavedChanges, categorizedFields } = + useCategoryStory(params); + + return ( + + + + ); +}; diff --git a/packages/kbn-management/settings/components/field_category/__stories__/category.stories.tsx b/packages/kbn-management/settings/components/field_category/__stories__/category.stories.tsx new file mode 100644 index 0000000000000..180f3d0b4ce49 --- /dev/null +++ b/packages/kbn-management/settings/components/field_category/__stories__/category.stories.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import type { ComponentMeta } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { getSettingsMock } from '@kbn/management-settings-utilities/mocks/settings.mock'; +import { getFieldDefinitions } from '@kbn/management-settings-field-definition'; +import { categorizeFields } from '@kbn/management-settings-utilities'; +import { FieldRow } from '@kbn/management-settings-components-field-row'; + +import { FieldCategory as Component, type FieldCategoryProps as ComponentProps } from '../category'; +import { Params, useCategoryStory } from './use_category_story'; +import { FieldCategoryProvider } from '../services'; + +const settings = getSettingsMock(); + +// Markdown and JSON fields require Monaco, which are *notoriously* slow in Storybook due +// to the lack of a webworker. Until we can resolve it, filter out those fields. +const definitions = getFieldDefinitions(settings, { + isCustom: () => { + return false; + }, + isOverridden: () => { + return false; + }, +}).filter((field) => field.type !== 'json' && field.type !== 'markdown'); + +const categories = Object.keys(categorizeFields(definitions)); + +export default { + title: 'Settings/Field Category/Category', + description: '', + args: { + category: categories[0], + isFiltered: false, + isSavingEnabled: true, + }, + argTypes: { + category: { + control: { + type: 'select', + options: categories, + }, + }, + }, +} as ComponentMeta; + +type FieldCategoryParams = Pick & Params; + +export const Category = ({ isFiltered, category, isSavingEnabled }: FieldCategoryParams) => { + const { onClearQuery, onFieldChange, unsavedChanges } = useCategoryStory({ + isFiltered, + isSavingEnabled, + }); + + const { count, fields } = categorizeFields(definitions)[category]; + const rows = isFiltered ? [fields[0]] : fields; + + return ( + + + {rows.map((field) => ( + + ))} + + + ); +}; diff --git a/packages/kbn-management/settings/components/field_category/__stories__/use_category_story.tsx b/packages/kbn-management/settings/components/field_category/__stories__/use_category_story.tsx new file mode 100644 index 0000000000000..73962fe9ed16c --- /dev/null +++ b/packages/kbn-management/settings/components/field_category/__stories__/use_category_story.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { useArgs } from '@storybook/client-api'; +import { action } from '@storybook/addon-actions'; + +import { getSettingsMock } from '@kbn/management-settings-utilities/mocks/settings.mock'; +import { getFieldDefinitions } from '@kbn/management-settings-field-definition'; +import { categorizeFields } from '@kbn/management-settings-utilities'; +import { UnsavedFieldChanges, OnFieldChangeFn } from '@kbn/management-settings-types'; + +export interface Params { + isFiltered: boolean; + isSavingEnabled: boolean; +} + +export const useCategoryStory = ({ isFiltered, isSavingEnabled }: Params) => { + const [_args, updateArgs] = useArgs(); + const settings = getSettingsMock(); + + // Markdown and JSON fields require Monaco, which are *notoriously* slow in Storybook due + // to the lack of a webworker. Until we can resolve it, filter out those fields. + const definitions = getFieldDefinitions(settings, { + isCustom: () => { + return false; + }, + isOverridden: () => { + return false; + }, + }).filter((field) => field.type !== 'json' && field.type !== 'markdown'); + + const categorizedFields = categorizeFields(definitions); + + if (isFiltered) { + Object.keys(categorizedFields).forEach((category) => { + categorizedFields[category].fields = categorizedFields[category].fields.slice(0, 1); + }); + } + + const onClearQuery = () => updateArgs({ isFiltered: false }); + + const [unsavedChanges, setUnsavedChanges] = React.useState({}); + + const onFieldChange: OnFieldChangeFn = (id, change) => { + action('onFieldChange')(id, change); + + if (!change) { + const { [id]: unsavedChange, ...rest } = unsavedChanges; + setUnsavedChanges(rest); + return; + } + + setUnsavedChanges((changes) => ({ ...changes, [id]: change })); + }; + + return { onClearQuery, onFieldChange, isSavingEnabled, unsavedChanges, categorizedFields }; +}; diff --git a/packages/kbn-management/settings/components/field_category/categories.tsx b/packages/kbn-management/settings/components/field_category/categories.tsx new file mode 100644 index 0000000000000..c732c29c4e4a2 --- /dev/null +++ b/packages/kbn-management/settings/components/field_category/categories.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { CategorizedFields, UnsavedFieldChanges } from '@kbn/management-settings-types'; + +import { FieldRow, FieldRowProps } from '@kbn/management-settings-components-field-row'; +import { FieldCategory, type FieldCategoryProps } from './category'; + +/** + * Props for the {@link FieldCategories} component. + */ +export interface FieldCategoriesProps + extends Pick, + Pick { + /** Categorized fields for display. */ + categorizedFields: CategorizedFields; + /** And unsaved changes currently managed by the parent component. */ + unsavedChanges?: UnsavedFieldChanges; +} + +/** + * Convenience component for displaying a set of {@link FieldCategory} components, given + * a set of categorized fields. + * + * @param {FieldCategoriesProps} props props to pass to the {@link FieldCategories} component. + */ +export const FieldCategories = ({ + categorizedFields, + unsavedChanges = {}, + onClearQuery, + isSavingEnabled, + onFieldChange, +}: FieldCategoriesProps) => ( + <> + {Object.entries(categorizedFields).map(([category, { count, fields }]) => ( + + {fields.map((field) => ( + + ))} + + ))} + +); diff --git a/packages/kbn-management/settings/components/field_category/category.tsx b/packages/kbn-management/settings/components/field_category/category.tsx new file mode 100644 index 0000000000000..47465c2ab0288 --- /dev/null +++ b/packages/kbn-management/settings/components/field_category/category.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { ReactElement, Children } from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiSplitPanel, EuiTitle, useEuiTheme } from '@elastic/eui'; + +import { getCategoryName } from '@kbn/management-settings-utilities'; +import type { FieldRowProps } from '@kbn/management-settings-components-field-row'; +import { css } from '@emotion/react'; +import { ClearQueryLink, ClearQueryLinkProps } from './clear_query_link'; + +/** + * Props for a {@link FieldCategory} component. + */ +export interface FieldCategoryProps + extends Pick { + /** The name of the category. */ + category: string; + /** Children-- should be {@link FieldRow} components. */ + children: + | ReactElement + | Array>; +} + +/** + * Component for displaying a container of fields pertaining to a single + * category. + * @param props - the props to pass to the {@link FieldCategory} component. + */ +export const FieldCategory = (props: FieldCategoryProps) => { + const { category, fieldCount, onClearQuery, children } = props; + const { + euiTheme: { size }, + } = useEuiTheme(); + + const displayCount = Children.count(children); + + const panelCSS = css` + & + & { + margin-top: ${size.l}; + } + `; + + return ( + + + + + +

{getCategoryName(category)}

+
+
+ + + +
+
+ {children} +
+ ); +}; diff --git a/packages/kbn-management/settings/components/field_category/clear_query_link.tsx b/packages/kbn-management/settings/components/field_category/clear_query_link.tsx new file mode 100644 index 0000000000000..c54b2c9ad52dc --- /dev/null +++ b/packages/kbn-management/settings/components/field_category/clear_query_link.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { css } from '@emotion/react'; + +import { EuiLink, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +/** + * Props for the {@link ClearQueryLink} component. + */ +export interface ClearQueryLinkProps { + /** The total number of fields in the category. */ + fieldCount: number; + /** The number of fields currently being displayed. */ + displayCount: number; + /** Handler to invoke when clearing the current filtering query. */ + onClearQuery: () => void; +} + +/** + * Component for displaying a link to clear the current filtering query. + */ +export const ClearQueryLink = ({ fieldCount, displayCount, onClearQuery }: ClearQueryLinkProps) => { + if (fieldCount === displayCount) { + return null; + } + + const linkCSS = css` + font-style: italic; + `; + + return ( + + + + + + + ), + }} + /> + + ); +}; diff --git a/packages/kbn-management/settings/components/field_category/index.ts b/packages/kbn-management/settings/components/field_category/index.ts new file mode 100644 index 0000000000000..38e0e914bfa89 --- /dev/null +++ b/packages/kbn-management/settings/components/field_category/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { FieldCategories, type FieldCategoriesProps } from './categories'; +export { FieldCategory, type FieldCategoryProps } from './category'; +export type { ClearQueryLinkProps } from './clear_query_link'; +export type { FieldCategoryKibanaDependencies, FieldCategoryServices } from './types'; +export { + FieldCategoryKibanaProvider, + FieldCategoryProvider, + type FieldCategoryProviderProps, +} from './services'; diff --git a/packages/kbn-management/settings/components/field_category/jest.config.js b/packages/kbn-management/settings/components/field_category/jest.config.js new file mode 100644 index 0000000000000..6569a209f7277 --- /dev/null +++ b/packages/kbn-management/settings/components/field_category/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/packages/kbn-management/settings/components/field_category'], +}; diff --git a/packages/kbn-management/settings/components/field_category/kibana.jsonc b/packages/kbn-management/settings/components/field_category/kibana.jsonc new file mode 100644 index 0000000000000..ac8859b05df4b --- /dev/null +++ b/packages/kbn-management/settings/components/field_category/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/management-settings-components-field-category", + "owner": "@elastic/platform-deployment-management" +} diff --git a/packages/kbn-management/settings/components/field_category/package.json b/packages/kbn-management/settings/components/field_category/package.json new file mode 100644 index 0000000000000..d8b9345da3086 --- /dev/null +++ b/packages/kbn-management/settings/components/field_category/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/management-settings-components-field-category", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/kbn-management/settings/components/field_category/services.tsx b/packages/kbn-management/settings/components/field_category/services.tsx new file mode 100644 index 0000000000000..2ff805196ec02 --- /dev/null +++ b/packages/kbn-management/settings/components/field_category/services.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { + FieldRowProvider, + FieldRowKibanaProvider, +} from '@kbn/management-settings-components-field-row'; + +import type { FieldCategoryServices } from './types'; + +/** + * Props for {@link FieldCategoryProvider}. + */ +export interface FieldCategoryProviderProps extends FieldCategoryServices { + children: React.ReactNode; +} + +/** + * React Provider that provides services to a {@link FieldCategory} component and its dependents. + */ +export const FieldCategoryProvider = FieldRowProvider; + +/** + * Kibana-specific Provider that maps Kibana plugins and services to a {@link FieldCategoryProvider}. + */ +export const FieldCategoryKibanaProvider = FieldRowKibanaProvider; diff --git a/packages/kbn-management/settings/components/field_category/tsconfig.json b/packages/kbn-management/settings/components/field_category/tsconfig.json new file mode 100644 index 0000000000000..8b4e9b1566b31 --- /dev/null +++ b/packages/kbn-management/settings/components/field_category/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/management-settings-utilities", + "@kbn/management-settings-field-definition", + "@kbn/management-settings-components-field-row", + "@kbn/management-settings-types", + "@kbn/i18n-react", + ] +} diff --git a/packages/kbn-management/settings/components/field_category/types.ts b/packages/kbn-management/settings/components/field_category/types.ts new file mode 100644 index 0000000000000..3f726f18300b9 --- /dev/null +++ b/packages/kbn-management/settings/components/field_category/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + FieldRowServices, + FieldRowKibanaDependencies, +} from '@kbn/management-settings-components-field-row'; + +/** + * Contextual services used by a {@link FieldCategory} component and its dependents. + */ +export type FieldCategoryServices = FieldRowServices; + +/** + * An interface containing a collection of Kibana plugins and services required to + * render a {@link FieldCategory} component and its dependents. + */ +export type FieldCategoryKibanaDependencies = FieldRowKibanaDependencies; diff --git a/packages/kbn-management/settings/components/field_input/__stories__/common.tsx b/packages/kbn-management/settings/components/field_input/__stories__/common.tsx index 399a125822a35..39daed80f40b8 100644 --- a/packages/kbn-management/settings/components/field_input/__stories__/common.tsx +++ b/packages/kbn-management/settings/components/field_input/__stories__/common.tsx @@ -13,7 +13,7 @@ import { action } from '@storybook/addon-actions'; import { EuiPanel } from '@elastic/eui'; import { UiSettingsType } from '@kbn/core-ui-settings-common'; import { - OnChangeFn, + OnInputChangeFn, SettingType, UiSettingMetadata, UnsavedFieldChange, @@ -108,17 +108,17 @@ export const getInputStory = (type: SettingType, params: Params = {}) => { setting, }); - const onChange: OnChangeFn = (newChange) => { + const onInputChange: OnInputChangeFn = (newChange) => { setUnsavedChange(newChange); - action('onChange')({ + action('onInputChange')({ type, unsavedValue: newChange?.unsavedValue, savedValue: field.savedValue, }); }; - return ; + return ; }; Story.argTypes = { diff --git a/packages/kbn-management/settings/components/field_input/field_input.test.tsx b/packages/kbn-management/settings/components/field_input/field_input.test.tsx index 0305636fd35e6..a946f259cab96 100644 --- a/packages/kbn-management/settings/components/field_input/field_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/field_input.test.tsx @@ -55,7 +55,7 @@ describe('FieldInput', () => { }, options, } as FieldDefinition, - onChange: jest.fn(), + onInputChange: jest.fn(), isSavingEnabled: true, }; @@ -132,7 +132,7 @@ describe('FieldInput', () => { const { getByTestId } = render(wrap()); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${name}`); fireEvent.change(input, { target: { value: 'new value' } }); - expect(props.onChange).toHaveBeenCalledWith({ type: 'string', unsavedValue: 'new value' }); + expect(props.onInputChange).toHaveBeenCalledWith({ type: 'string', unsavedValue: 'new value' }); }); it('disables the input when isDisabled prop is true', () => { @@ -191,7 +191,7 @@ describe('FieldInput', () => { ...defaultProps.field, type: 'foobar', }, - } as unknown as FieldInputProps; + } as unknown as FieldInputProps; expect(() => render(wrap())).toThrowError( 'Unknown or incompatible field type: foobar' diff --git a/packages/kbn-management/settings/components/field_input/field_input.tsx b/packages/kbn-management/settings/components/field_input/field_input.tsx index ae9e3c5c6c536..a5b3fdb4aac55 100644 --- a/packages/kbn-management/settings/components/field_input/field_input.tsx +++ b/packages/kbn-management/settings/components/field_input/field_input.tsx @@ -10,7 +10,7 @@ import React, { useImperativeHandle, useRef } from 'react'; import type { FieldDefinition, - OnChangeFn, + OnInputChangeFn, ResetInputRef, SettingType, UnsavedFieldChange, @@ -56,13 +56,13 @@ import { /** * The props that are passed to the {@link FieldInput} component. */ -export interface FieldInputProps { +export interface FieldInputProps { /** The {@link FieldDefinition} for the component. */ field: Pick, 'type' | 'id' | 'name' | 'ariaAttributes'>; /** An {@link UnsavedFieldChange} for the component, if any. */ unsavedChange?: UnsavedFieldChange; - /** The `onChange` handler for the input. */ - onChange: OnChangeFn; + /** The `onInputChange` handler for the input. */ + onInputChange: OnInputChangeFn; /** True if the input can be saved, false otherwise. */ isSavingEnabled: boolean; /** True if the value within the input is invalid, false otherwise. */ @@ -81,140 +81,136 @@ const getMismatchError = (type: SettingType, unsavedType?: SettingType) => * * @param props The props for the {@link FieldInput} component. */ -export const FieldInput = React.forwardRef>( - (props, ref) => { - const { field, unsavedChange, onChange, isSavingEnabled } = props; - - // Create a ref for those input fields that require an imperative handle. - const inputRef = useRef(null); - - // Create an imperative handle that passes the invocation to any internal input that - // may require it. - useImperativeHandle(ref, () => ({ - reset: () => { - if (inputRef.current) { - inputRef.current.reset(); - } - }, - })); - - const inputProps = { isSavingEnabled, onChange }; - - // These checks might seem excessive or redundant, but they are necessary to ensure that - // the types are honored correctly using type guards. These checks get compiled down to - // checks against the `type` property-- which we were doing in the previous code, albeit - // in an unenforceable way. - // - // Based on the success of a check, we can render the `FieldInput` in a indempotent and - // type-safe way. - // - if (isArrayFieldDefinition(field)) { - // If the composing component mistakenly provides an incompatible `UnsavedFieldChange`, - // we can throw an `Error`. We might consider switching to a `console.error` and not - // rendering the input, but that might be less helpful. - if (!isArrayFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); +export const FieldInput = React.forwardRef((props, ref) => { + const { field, unsavedChange, onInputChange, isSavingEnabled } = props; + + // Create a ref for those input fields that require an imperative handle. + const inputRef = useRef(null); + + // Create an imperative handle that passes the invocation to any internal input that + // may require it. + useImperativeHandle(ref, () => ({ + reset: () => { + if (inputRef.current) { + inputRef.current.reset(); } - - return ; + }, + })); + + const inputProps = { isSavingEnabled, onInputChange }; + + // These checks might seem excessive or redundant, but they are necessary to ensure that + // the types are honored correctly using type guards. These checks get compiled down to + // checks against the `type` property-- which we were doing in the previous code, albeit + // in an unenforceable way. + // + // Based on the success of a check, we can render the `FieldInput` in a indempotent and + // type-safe way. + // + if (isArrayFieldDefinition(field)) { + // If the composing component mistakenly provides an incompatible `UnsavedFieldChange`, + // we can throw an `Error`. We might consider switching to a `console.error` and not + // rendering the input, but that might be less helpful. + if (!isArrayFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); } - if (isBooleanFieldDefinition(field)) { - if (!isBooleanFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); - } + return ; + } - return ; + if (isBooleanFieldDefinition(field)) { + if (!isBooleanFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); } - if (isColorFieldDefinition(field)) { - if (!isColorFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); - } + return ; + } - return ; + if (isColorFieldDefinition(field)) { + if (!isColorFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); } - if (isImageFieldDefinition(field)) { - if (!isImageFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); - } + return ; + } - return ; + if (isImageFieldDefinition(field)) { + if (!isImageFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); } - if (isJsonFieldDefinition(field)) { - if (!isJsonFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); - } + return ; + } - return ( - - ); + if (isJsonFieldDefinition(field)) { + if (!isJsonFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); } - if (isMarkdownFieldDefinition(field)) { - if (!isMarkdownFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); - } + return ( + + ); + } - return ( - - ); + if (isMarkdownFieldDefinition(field)) { + if (!isMarkdownFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); } - if (isNumberFieldDefinition(field)) { - if (!isNumberFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); - } + return ( + + ); + } - return ; + if (isNumberFieldDefinition(field)) { + if (!isNumberFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); } - if (isSelectFieldDefinition(field)) { - if (!isSelectFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); - } - - const { - options: { values: optionValues, labels: optionLabels }, - } = field; + return ; + } - return ( - - ); + if (isSelectFieldDefinition(field)) { + if (!isSelectFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); } - if (isStringFieldDefinition(field)) { - if (!isStringFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); - } + const { + options: { values: optionValues, labels: optionLabels }, + } = field; - return ; + return ; + } + + if (isStringFieldDefinition(field)) { + if (!isStringFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); } - if (isUndefinedFieldDefinition(field)) { - if (!isUndefinedFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); - } + return ; + } - return ( - } - unsavedChange={unsavedChange as unknown as UnsavedFieldChange<'string'>} - {...inputProps} - /> - ); + if (isUndefinedFieldDefinition(field)) { + if (!isUndefinedFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); } - throw new Error(`Unknown or incompatible field type: ${field.type}`); + return ( + } + unsavedChange={unsavedChange as unknown as UnsavedFieldChange<'string'>} + {...inputProps} + /> + ); } -); + + throw new Error(`Unknown or incompatible field type: ${field.type}`); +}); diff --git a/packages/kbn-management/settings/components/field_input/input/array_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/array_input.test.tsx index 6f80de3039b70..c954035e9c639 100644 --- a/packages/kbn-management/settings/components/field_input/input/array_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/array_input.test.tsx @@ -18,9 +18,9 @@ const name = 'Some array field'; const id = 'some:array:field'; describe('ArrayInput', () => { - const onChange = jest.fn(); + const onInputChange = jest.fn(); const defaultProps: InputProps<'array'> = { - onChange, + onInputChange, field: { name, type: 'array', @@ -35,7 +35,7 @@ describe('ArrayInput', () => { }; beforeEach(() => { - onChange.mockClear(); + onInputChange.mockClear(); }); it('renders without errors', () => { @@ -70,7 +70,7 @@ describe('ArrayInput', () => { expect(input).toHaveValue('foo, bar, baz'); }); - it('only calls onChange when blurred ', () => { + it('only calls onInputChange when blurred ', () => { render(wrap()); const input = screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); @@ -78,13 +78,13 @@ describe('ArrayInput', () => { userEvent.type(input, ',baz'); expect(input).toHaveValue('foo, bar,baz'); - expect(defaultProps.onChange).not.toHaveBeenCalled(); + expect(defaultProps.onInputChange).not.toHaveBeenCalled(); act(() => { input.blur(); }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'array', unsavedValue: ['foo', 'bar', 'baz'], }); diff --git a/packages/kbn-management/settings/components/field_input/input/array_input.tsx b/packages/kbn-management/settings/components/field_input/input/array_input.tsx index dfc92dc980bbd..f2ce42051eb04 100644 --- a/packages/kbn-management/settings/components/field_input/input/array_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/array_input.tsx @@ -29,7 +29,7 @@ export const ArrayInput = ({ field, unsavedChange, isSavingEnabled, - onChange: onChangeProp, + onInputChange, }: ArrayInputProps) => { const [inputValue] = getFieldInputValue(field, unsavedChange) || []; const [value, setValue] = useState(inputValue?.join(', ')); @@ -39,7 +39,7 @@ export const ArrayInput = ({ setValue(newValue); }; - const onUpdate = useUpdate({ onChange: onChangeProp, field }); + const onUpdate = useUpdate({ onInputChange, field }); useEffect(() => { setValue(inputValue?.join(', ')); diff --git a/packages/kbn-management/settings/components/field_input/input/boolean_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/boolean_input.test.tsx index b9f3ac883421b..49bc4e9367a65 100644 --- a/packages/kbn-management/settings/components/field_input/input/boolean_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/boolean_input.test.tsx @@ -19,9 +19,9 @@ const name = 'Some boolean field'; const id = 'some:boolean:field'; describe('BooleanInput', () => { - const onChange = jest.fn(); + const onInputChange = jest.fn(); const defaultProps: InputProps<'boolean'> = { - onChange, + onInputChange, field: { name, type: 'boolean', @@ -36,7 +36,7 @@ describe('BooleanInput', () => { }; beforeEach(() => { - onChange.mockClear(); + onInputChange.mockClear(); }); it('renders false', () => { @@ -60,16 +60,16 @@ describe('BooleanInput', () => { expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toBeChecked(); }); - it('calls onChange when toggled', () => { + it('calls onInputChange when toggled', () => { render(wrap()); const input = screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); - expect(defaultProps.onChange).not.toHaveBeenCalled(); + expect(defaultProps.onInputChange).not.toHaveBeenCalled(); act(() => { fireEvent.click(input); }); - expect(defaultProps.onChange).toBeCalledWith({ type: 'boolean', unsavedValue: true }); + expect(defaultProps.onInputChange).toBeCalledWith({ type: 'boolean', unsavedValue: true }); act(() => { fireEvent.click(input); diff --git a/packages/kbn-management/settings/components/field_input/input/boolean_input.tsx b/packages/kbn-management/settings/components/field_input/input/boolean_input.tsx index 4f523da8067eb..782bff10a1ee3 100644 --- a/packages/kbn-management/settings/components/field_input/input/boolean_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/boolean_input.tsx @@ -28,15 +28,15 @@ export const BooleanInput = ({ field, unsavedChange, isSavingEnabled, - onChange: onChangeProp, + onInputChange, }: BooleanInputProps) => { + const onUpdate = useUpdate({ onInputChange, field }); + const onChange: EuiSwitchProps['onChange'] = (event) => { const inputValue = event.target.checked; onUpdate({ type: field.type, unsavedValue: inputValue }); }; - const onUpdate = useUpdate({ onChange: onChangeProp, field }); - const { id, name, ariaAttributes } = field; const { ariaLabel, ariaDescribedBy } = ariaAttributes; const [value] = getFieldInputValue(field, unsavedChange); diff --git a/packages/kbn-management/settings/components/field_input/input/code_editor_input.tsx b/packages/kbn-management/settings/components/field_input/input/code_editor_input.tsx index dc6c1e15043aa..5b9d90154d087 100644 --- a/packages/kbn-management/settings/components/field_input/input/code_editor_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/code_editor_input.tsx @@ -43,9 +43,9 @@ export const CodeEditorInput = ({ type, isSavingEnabled, defaultValue, - onChange: onChangeProp, + onInputChange, }: CodeEditorInputProps) => { - const onUpdate = useUpdate({ onChange: onChangeProp, field }); + const onUpdate = useUpdate({ onInputChange, field }); const onChange: CodeEditorProps['onChange'] = (inputValue) => { let newUnsavedValue; diff --git a/packages/kbn-management/settings/components/field_input/input/color_picker_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/color_picker_input.test.tsx index 0bd73ad51645c..4214b4b37bdf1 100644 --- a/packages/kbn-management/settings/components/field_input/input/color_picker_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/color_picker_input.test.tsx @@ -15,9 +15,9 @@ const name = 'Some color field'; const id = 'some:color:field'; describe('ColorPickerInput', () => { - const onChange = jest.fn(); + const onInputChange = jest.fn(); const defaultProps: ColorPickerInputProps = { - onChange, + onInputChange, field: { name, type: 'color', @@ -32,7 +32,7 @@ describe('ColorPickerInput', () => { }; beforeEach(() => { - onChange.mockClear(); + onInputChange.mockClear(); }); it('renders without errors', () => { @@ -42,20 +42,23 @@ describe('ColorPickerInput', () => { expect(input).toHaveValue('#000000'); }); - it('calls the onChange prop when the value changes', () => { + it('calls the onInputChange prop when the value changes', () => { const { getByRole } = render(wrap()); const input = getByRole('textbox'); const newValue = '#ffffff'; fireEvent.change(input, { target: { value: newValue } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'color', unsavedValue: newValue }); + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ + type: 'color', + unsavedValue: newValue, + }); }); - it('calls the onChange prop with an error when the value is malformed', () => { + it('calls the onInputChange prop with an error when the value is malformed', () => { const { getByRole } = render(wrap()); const input = getByRole('textbox'); const newValue = '#1234'; fireEvent.change(input, { target: { value: newValue } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'color', unsavedValue: newValue, isInvalid: true, diff --git a/packages/kbn-management/settings/components/field_input/input/color_picker_input.tsx b/packages/kbn-management/settings/components/field_input/input/color_picker_input.tsx index 8533fc0545eab..41f41ea0638fc 100644 --- a/packages/kbn-management/settings/components/field_input/input/color_picker_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/color_picker_input.tsx @@ -32,9 +32,9 @@ export const ColorPickerInput = ({ field, unsavedChange, isSavingEnabled, - onChange: onChangeProp, + onInputChange, }: ColorPickerInputProps) => { - const onUpdate = useUpdate({ onChange: onChangeProp, field }); + const onUpdate = useUpdate({ onInputChange, field }); const onChange: EuiColorPickerProps['onChange'] = (newColor, { isValid }) => { const update: UnsavedFieldChange<'color'> = { type: field.type, unsavedValue: newColor }; diff --git a/packages/kbn-management/settings/components/field_input/input/image_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/image_input.test.tsx index cbbec332330d1..3d0c47d177822 100644 --- a/packages/kbn-management/settings/components/field_input/input/image_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/image_input.test.tsx @@ -18,9 +18,9 @@ const name = 'Some image field'; const id = 'some:image:field'; describe('ImageInput', () => { - const onChange = jest.fn(); + const onInputChange = jest.fn(); const defaultProps: ImageInputProps = { - onChange, + onInputChange, field: { name, type: 'image', @@ -35,7 +35,7 @@ describe('ImageInput', () => { }; beforeEach(() => { - onChange.mockClear(); + onInputChange.mockClear(); }); it('renders without errors', () => { @@ -43,7 +43,7 @@ describe('ImageInput', () => { expect(container).toBeInTheDocument(); }); - it('calls the onChange prop when a file is selected', async () => { + it('calls the onInputChange prop when a file is selected', async () => { const { getByTestId } = render(wrap()); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`) as HTMLInputElement; const file = new File(['(⌐□_□)'], 'test.png', { type: 'image/png' }); @@ -55,7 +55,7 @@ describe('ImageInput', () => { expect(input.files?.length).toBe(1); // This doesn't work for some reason. - // expect(defaultProps.onChange).toHaveBeenCalledWith({ value: file }); + // expect(defaultProps.onInputChange).toHaveBeenCalledWith({ value: file }); }); it('disables the input when isDisabled prop is true', () => { diff --git a/packages/kbn-management/settings/components/field_input/input/image_input.tsx b/packages/kbn-management/settings/components/field_input/input/image_input.tsx index 286cba2c49d4c..9bcacf620c1fe 100644 --- a/packages/kbn-management/settings/components/field_input/input/image_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/image_input.tsx @@ -44,7 +44,7 @@ const errorMessage = i18n.translate('management.settings.field.imageChangeErrorM * Component for manipulating an `image` field. */ export const ImageInput = React.forwardRef( - ({ field, unsavedChange, isSavingEnabled, onChange: onChangeProp }, ref) => { + ({ field, unsavedChange, isSavingEnabled, onInputChange }, ref) => { const inputRef = useRef(null); useImperativeHandle(ref, () => ({ @@ -53,7 +53,7 @@ export const ImageInput = React.forwardRef( const { showDanger } = useServices(); - const onUpdate = useUpdate({ onChange: onChangeProp, field }); + const onUpdate = useUpdate({ onInputChange, field }); const onChange: EuiFilePickerProps['onChange'] = async (files: FileList | null) => { if (files === null || !files.length) { diff --git a/packages/kbn-management/settings/components/field_input/input/json_editor_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/json_editor_input.test.tsx index 2cd34de067ffc..800a807f8103a 100644 --- a/packages/kbn-management/settings/components/field_input/input/json_editor_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/json_editor_input.test.tsx @@ -33,9 +33,9 @@ jest.mock('../code_editor', () => ({ })); describe('JsonEditorInput', () => { - const onChange = jest.fn(); + const onInputChange = jest.fn(); const defaultProps: CodeEditorInputProps = { - onChange, + onInputChange, type: 'json', field: { name, @@ -51,7 +51,7 @@ describe('JsonEditorInput', () => { }; beforeEach(() => { - onChange.mockClear(); + onInputChange.mockClear(); }); it('renders without errors', () => { @@ -65,28 +65,28 @@ describe('JsonEditorInput', () => { expect(input).toHaveValue(initialValue); }); - it('calls the onChange prop when the object value changes', () => { + it('calls the onInputChange prop when the object value changes', () => { const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '{"bar":"foo"}' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '{"bar":"foo"}', }); }); - it('calls the onChange prop when the object value changes with no value', () => { + it('calls the onInputChange prop when the object value changes with no value', () => { const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' }); + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' }); }); - it('calls the onChange prop with an error when the object value changes to invalid JSON', () => { + it('calls the onInputChange prop with an error when the object value changes to invalid JSON', () => { const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '{"bar" "foo"}' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '{"bar" "foo"}', error: 'Invalid JSON syntax', @@ -94,20 +94,20 @@ describe('JsonEditorInput', () => { }); }); - it('calls the onChange prop when the array value changes', () => { + it('calls the onInputChange prop when the array value changes', () => { const props = { ...defaultProps, defaultValue: '["bar", "foo"]', value: undefined }; const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '["foo", "bar", "baz"]' } }); waitFor(() => - expect(defaultProps.onChange).toHaveBeenCalledWith({ + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '["foo", "bar", "baz"]', }) ); }); - it('calls the onChange prop when the array value changes with no value', () => { + it('calls the onInputChange prop when the array value changes with no value', () => { const props = { ...defaultProps, defaultValue: '["bar", "foo"]', @@ -116,15 +116,15 @@ describe('JsonEditorInput', () => { const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' }); + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' }); }); - it('calls the onChange prop with an array when the array value changes to invalid JSON', () => { + it('calls the onInputChange prop with an array when the array value changes to invalid JSON', () => { const props = { ...defaultProps, defaultValue: '["bar", "foo"]', value: undefined }; const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '["bar", "foo" | "baz"]' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '["bar", "foo" | "baz"]', error: 'Invalid JSON syntax', diff --git a/packages/kbn-management/settings/components/field_input/input/markdown_editor_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/markdown_editor_input.test.tsx index dd15b250cf1e0..291585e5f149a 100644 --- a/packages/kbn-management/settings/components/field_input/input/markdown_editor_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/markdown_editor_input.test.tsx @@ -33,9 +33,9 @@ jest.mock('../code_editor', () => ({ })); describe('MarkdownEditorInput', () => { - const onChange = jest.fn(); + const onInputChange = jest.fn(); const defaultProps: CodeEditorInputProps = { - onChange, + onInputChange, type: 'markdown', field: { name, @@ -51,7 +51,7 @@ describe('MarkdownEditorInput', () => { }; beforeEach(() => { - onChange.mockClear(); + onInputChange.mockClear(); }); it('renders without errors', () => { @@ -65,11 +65,11 @@ describe('MarkdownEditorInput', () => { expect(input).toHaveValue(initialValue); }); - it('calls the onChange prop when the value changes', () => { + it('calls the onInputChange prop when the value changes', () => { const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '# New Markdown Title' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'markdown', unsavedValue: '# New Markdown Title', }); diff --git a/packages/kbn-management/settings/components/field_input/input/number_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/number_input.test.tsx index 3fd6518102a46..fa228a48b721e 100644 --- a/packages/kbn-management/settings/components/field_input/input/number_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/number_input.test.tsx @@ -16,9 +16,9 @@ const name = 'Some number field'; const id = 'some:number:field'; describe('NumberInput', () => { - const onChange = jest.fn(); + const onInputChange = jest.fn(); const defaultProps: NumberInputProps = { - onChange, + onInputChange, field: { name, type: 'number', @@ -33,7 +33,7 @@ describe('NumberInput', () => { }; beforeEach(() => { - onChange.mockClear(); + onInputChange.mockClear(); }); it('renders without errors', () => { @@ -65,11 +65,14 @@ describe('NumberInput', () => { expect(input).toHaveValue(4321); }); - it('calls the onChange prop when the value changes', () => { + it('calls the onInputChange prop when the value changes', () => { const { getByTestId } = render(wrap()); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '54321' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'number', unsavedValue: 54321 }); + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ + type: 'number', + unsavedValue: 54321, + }); }); it('disables the input when isDisabled prop is true', () => { diff --git a/packages/kbn-management/settings/components/field_input/input/number_input.tsx b/packages/kbn-management/settings/components/field_input/input/number_input.tsx index f67e27be505e2..a1929593e3cb5 100644 --- a/packages/kbn-management/settings/components/field_input/input/number_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/number_input.tsx @@ -26,14 +26,14 @@ export const NumberInput = ({ field, unsavedChange, isSavingEnabled, - onChange: onChangeProp, + onInputChange, }: NumberInputProps) => { const onChange: EuiFieldNumberProps['onChange'] = (event) => { const inputValue = Number(event.target.value); onUpdate({ type: field.type, unsavedValue: inputValue }); }; - const onUpdate = useUpdate({ onChange: onChangeProp, field }); + const onUpdate = useUpdate({ onInputChange, field }); const { id, name, ariaAttributes } = field; const { ariaLabel, ariaDescribedBy } = ariaAttributes; diff --git a/packages/kbn-management/settings/components/field_input/input/select_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/select_input.test.tsx index ca2e875a65604..e77d2a2cbfe49 100644 --- a/packages/kbn-management/settings/components/field_input/input/select_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/select_input.test.tsx @@ -16,9 +16,9 @@ const name = 'Some select field'; const id = 'some:select:field'; describe('SelectInput', () => { - const onChange = jest.fn(); + const onInputChange = jest.fn(); const defaultProps: SelectInputProps = { - onChange, + onInputChange, field: { name, type: 'select', @@ -39,7 +39,7 @@ describe('SelectInput', () => { }; beforeEach(() => { - onChange.mockClear(); + onInputChange.mockClear(); }); it('renders without errors', () => { @@ -49,11 +49,14 @@ describe('SelectInput', () => { expect(input).toHaveValue('option2'); }); - it('calls the onChange prop when the value changes', () => { + it('calls the onInputChange prop when the value changes', () => { const { getByTestId } = render(wrap()); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: 'option3' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'select', unsavedValue: 'option3' }); + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ + type: 'select', + unsavedValue: 'option3', + }); }); it('disables the input when isDisabled prop is true', () => { diff --git a/packages/kbn-management/settings/components/field_input/input/select_input.tsx b/packages/kbn-management/settings/components/field_input/input/select_input.tsx index bd53fb9913ec5..9421d4d3e83b1 100644 --- a/packages/kbn-management/settings/components/field_input/input/select_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/select_input.tsx @@ -30,7 +30,7 @@ export interface SelectInputProps extends InputProps<'select'> { export const SelectInput = ({ field, unsavedChange, - onChange: onChangeProp, + onInputChange, optionLabels = {}, optionValues: optionsProp, isSavingEnabled, @@ -53,7 +53,7 @@ export const SelectInput = ({ onUpdate({ type: field.type, unsavedValue: inputValue }); }; - const onUpdate = useUpdate({ onChange: onChangeProp, field }); + const onUpdate = useUpdate({ onInputChange, field }); const { id, ariaAttributes } = field; const { ariaLabel, ariaDescribedBy } = ariaAttributes; diff --git a/packages/kbn-management/settings/components/field_input/input/text_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/text_input.test.tsx index 9dcdb8a04d5ea..e0d2eb75fca90 100644 --- a/packages/kbn-management/settings/components/field_input/input/text_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/text_input.test.tsx @@ -16,9 +16,9 @@ const name = 'Some text field'; const id = 'some:text:field'; describe('TextInput', () => { - const onChange = jest.fn(); + const onInputChange = jest.fn(); const defaultProps: TextInputProps = { - onChange, + onInputChange, field: { name, type: 'string', @@ -33,7 +33,7 @@ describe('TextInput', () => { }; beforeEach(() => { - onChange.mockClear(); + onInputChange.mockClear(); }); it('renders without errors', () => { @@ -47,11 +47,11 @@ describe('TextInput', () => { expect(input).toHaveValue('initial value'); }); - it('calls the onChange prop when the value changes', () => { + it('calls the onInputChange prop when the value changes', () => { const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: 'new value' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ + expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'string', unsavedValue: 'new value', }); diff --git a/packages/kbn-management/settings/components/field_input/input/text_input.tsx b/packages/kbn-management/settings/components/field_input/input/text_input.tsx index f4f9450e9577f..711d7aa808344 100644 --- a/packages/kbn-management/settings/components/field_input/input/text_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/text_input.tsx @@ -26,14 +26,14 @@ export const TextInput = ({ field, unsavedChange, isSavingEnabled, - onChange: onChangeProp, + onInputChange, }: TextInputProps) => { const onChange: EuiFieldTextProps['onChange'] = (event) => { const inputValue = event.target.value; onUpdate({ type: field.type, unsavedValue: inputValue }); }; - const onUpdate = useUpdate({ onChange: onChangeProp, field }); + const onUpdate = useUpdate({ onInputChange, field }); const { id, name, ariaAttributes } = field; const { ariaLabel, ariaDescribedBy } = ariaAttributes; diff --git a/packages/kbn-management/settings/components/field_input/types.ts b/packages/kbn-management/settings/components/field_input/types.ts index e5d5c11e2f199..fc4d311a3d210 100644 --- a/packages/kbn-management/settings/components/field_input/types.ts +++ b/packages/kbn-management/settings/components/field_input/types.ts @@ -8,7 +8,7 @@ import { FieldDefinition, - OnChangeFn, + OnInputChangeFn, SettingType, UnsavedFieldChange, } from '@kbn/management-settings-types'; @@ -44,6 +44,6 @@ export interface InputProps { >; unsavedChange?: UnsavedFieldChange; isSavingEnabled: boolean; - /** The `onChange` handler. */ - onChange: OnChangeFn; + /** The `onInputChange` handler. */ + onInputChange: OnInputChangeFn; } diff --git a/packages/kbn-management/settings/components/field_row/README.mdx b/packages/kbn-management/settings/components/field_row/README.mdx index 6fe238938407c..d8febe27dd674 100644 --- a/packages/kbn-management/settings/components/field_row/README.mdx +++ b/packages/kbn-management/settings/components/field_row/README.mdx @@ -19,7 +19,7 @@ For reference, this is an example of the current Advanced Settings UI: ## Implementation -A `FormRow` represents a single UiSetting, and is responsible for rendering the UiSetting's label, description, and equivalent value input. It displays the state of any unsaved change, (e.g. error). It also handles the logic for updating the UiSetting's value in a consuming component through the `onChange` handler. +A `FormRow` represents a single UiSetting, and is responsible for rendering the UiSetting's label, description, and equivalent value input. It displays the state of any unsaved change, (e.g. error). It also handles the logic for updating the UiSetting's value in a consuming component through the `onFieldChange` handler.
Anatomy of a `FormRow`
diff --git a/packages/kbn-management/settings/components/field_row/__stories__/common.tsx b/packages/kbn-management/settings/components/field_row/__stories__/common.tsx index 58e145146cc40..e8c51d075c21d 100644 --- a/packages/kbn-management/settings/components/field_row/__stories__/common.tsx +++ b/packages/kbn-management/settings/components/field_row/__stories__/common.tsx @@ -12,12 +12,15 @@ import { action } from '@storybook/addon-actions'; import { EuiPanel } from '@elastic/eui'; import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types'; -import { KnownTypeToMetadata, UiSettingMetadata } from '@kbn/management-settings-types/metadata'; +import { + KnownTypeToMetadata, + UiSettingMetadata, + OnFieldChangeFn, +} from '@kbn/management-settings-types'; import { getDefaultValue, getUserValue } from '@kbn/management-settings-utilities/storybook'; import { getFieldDefinition } from '@kbn/management-settings-field-definition'; import { FieldRow as Component, FieldRow } from '../field_row'; import { FieldRowProvider } from '../services'; -import { RowOnChangeFn } from '../types'; /** * Props for a {@link FieldInput} Storybook story. @@ -103,10 +106,7 @@ export const storyArgs = { * @param type The type of the UiSetting for this {@link FieldRow}. * @returns A Storybook Story. */ -export const getFieldRowStory = ( - type: SettingType, - settingFields?: Partial> -) => { +export const getFieldRowStory = (type: SettingType, settingFields?: Partial) => { const Story = ({ isCustom, isDeprecated, @@ -143,17 +143,17 @@ export const getFieldRowStory = ( }, }); - const onChange: RowOnChangeFn = (_id, newChange) => { + const onFieldChange: OnFieldChangeFn = (_id, newChange) => { setUnsavedChange(newChange); - action('onChange')({ + action('onFieldChange')({ type, unsavedValue: newChange?.unsavedValue, savedValue: field.savedValue, }); }; - return ; + return ; }; // In Kibana, the image default value is never anything other than null. There would be a number diff --git a/packages/kbn-management/settings/components/field_row/field_row.test.tsx b/packages/kbn-management/settings/components/field_row/field_row.test.tsx index afa425d2a459a..e64d32366409e 100644 --- a/packages/kbn-management/settings/components/field_row/field_row.test.tsx +++ b/packages/kbn-management/settings/components/field_row/field_row.test.tsx @@ -198,7 +198,7 @@ describe('Field', () => { wrap( ) @@ -212,7 +212,7 @@ describe('Field', () => { wrap( ) @@ -243,7 +243,7 @@ describe('Field', () => { setting, params: { isOverridden: true }, })} - onChange={handleChange} + onFieldChange={handleChange} isSavingEnabled={true} /> ) @@ -265,7 +265,7 @@ describe('Field', () => { id, setting, })} - onChange={handleChange} + onFieldChange={handleChange} isSavingEnabled={false} /> ) @@ -288,7 +288,7 @@ describe('Field', () => { userValue: userValues[type] as any, }, })} - onChange={handleChange} + onFieldChange={handleChange} isSavingEnabled={true} /> ) @@ -319,7 +319,7 @@ describe('Field', () => { setting, params: { isCustom: true }, })} - onChange={handleChange} + onFieldChange={handleChange} isSavingEnabled={true} /> ) @@ -341,7 +341,7 @@ describe('Field', () => { type, unsavedValue: userValues[type] as any, }} - onChange={handleChange} + onFieldChange={handleChange} isSavingEnabled={true} /> ) @@ -373,7 +373,7 @@ describe('Field', () => { }); const { getByTestId } = render( - wrap() + wrap() ); const input = getByTestId(`${DATA_TEST_SUBJ_RESET_PREFIX}-${field.id}`); @@ -395,7 +395,7 @@ describe('Field', () => { ) @@ -408,12 +408,12 @@ describe('Field', () => { }); }); - it('should fire onChange when input changes', () => { + it('should fire onFieldChange when input changes', () => { const setting = settings.string; const field = getFieldDefinition({ id: setting.name || setting.type, setting }); const { getByTestId } = render( - wrap() + wrap() ); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${field.id}`); @@ -424,12 +424,12 @@ describe('Field', () => { }); }); - it('should fire onChange with an error when input changes with invalid value', () => { + it('should fire onFieldChange with an error when input changes with invalid value', () => { const setting = settings.color; const field = getFieldDefinition({ id: setting.name || setting.type, setting }); const { getByTestId } = render( - wrap() + wrap() ); const input = getByTestId(`euiColorPickerAnchor ${TEST_SUBJ_PREFIX_FIELD}-${field.id}`); @@ -451,7 +451,7 @@ describe('Field', () => { wrap( { const { getByTestId } = render( wrap( - + ) ); @@ -512,7 +516,7 @@ describe('Field', () => { }); const { getByTestId, getByAltText } = render( - wrap() + wrap() ); const link = getByTestId(`${DATA_TEST_SUBJ_CHANGE_LINK_PREFIX}-${field.id}`); @@ -534,7 +538,7 @@ describe('Field', () => { wrap( diff --git a/packages/kbn-management/settings/components/field_row/field_row.tsx b/packages/kbn-management/settings/components/field_row/field_row.tsx index 9058511c955d1..3592e634ade5e 100644 --- a/packages/kbn-management/settings/components/field_row/field_row.tsx +++ b/packages/kbn-management/settings/components/field_row/field_row.tsx @@ -21,7 +21,8 @@ import type { ResetInputRef, SettingType, UnsavedFieldChange, - OnChangeFn, + OnInputChangeFn, + OnFieldChangeFn, } from '@kbn/management-settings-types'; import { isImageFieldDefinition } from '@kbn/management-settings-field-definition'; import { FieldInput } from '@kbn/management-settings-components-field-input'; @@ -30,12 +31,11 @@ import { hasUnsavedChange } from '@kbn/management-settings-utilities'; import { FieldDescription } from './description'; import { FieldTitle } from './title'; import { useFieldStyles } from './field_row.styles'; -import { RowOnChangeFn } from './types'; import { FieldInputFooter } from './footer'; export const DATA_TEST_SUBJ_SCREEN_READER_MESSAGE = 'fieldRowScreenReaderMessage'; -type Definition = Pick< +type Definition = Pick< FieldDefinition, | 'ariaAttributes' | 'defaultValue' @@ -57,18 +57,18 @@ type Definition = Pick< */ export interface FieldRowProps { /** The {@link FieldDefinition} corresponding the setting. */ - field: Definition; + field: Definition; /** True if saving settings is enabled, false otherwise. */ isSavingEnabled: boolean; - /** The {@link OnChangeFn} handler. */ - onChange: RowOnChangeFn; + /** The {@link OnInputChangeFn} handler. */ + onFieldChange: OnFieldChangeFn; /** * The onClear handler, if a value is cleared to an empty or default state. * @param id The id relating to the field to clear. */ onClear?: (id: string) => void; /** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */ - unsavedChange?: UnsavedFieldChange; + unsavedChange?: UnsavedFieldChange; } /** @@ -76,7 +76,7 @@ export interface FieldRowProps { * @param props The {@link FieldRowProps} for the {@link FieldRow} component. */ export const FieldRow = (props: FieldRowProps) => { - const { isSavingEnabled, onChange: onChangeProp, field, unsavedChange } = props; + const { isSavingEnabled, onFieldChange, field, unsavedChange } = props; const { id, groupId, isOverridden, unsavedFieldId } = field; const { cssFieldFormGroup } = useFieldStyles({ field, @@ -86,9 +86,9 @@ export const FieldRow = (props: FieldRowProps) => { // Create a ref for those input fields that use a `reset` handle. const ref = useRef(null); - // Route any change to the `onChange` handler, along with the field id. - const onChange: OnChangeFn = (update) => { - onChangeProp(id, update); + // Route any change to the `onFieldChange` handler, along with the field id. + const onInputChange: OnInputChangeFn = (update) => { + onFieldChange(id, update); }; const onReset = () => { @@ -97,9 +97,9 @@ export const FieldRow = (props: FieldRowProps) => { const update = { type: field.type, unsavedValue: field.defaultValue }; if (hasUnsavedChange(field, update)) { - onChange(update); + onInputChange(update); } else { - onChange(); + onInputChange(); } }; @@ -111,9 +111,9 @@ export const FieldRow = (props: FieldRowProps) => { // Indicate a field is being cleared for a new value by setting its unchanged // value to`undefined`. Currently, this only applies to `image` fields. if (field.savedValue !== undefined && field.savedValue !== null) { - onChange({ type: field.type, unsavedValue: undefined }); + onInputChange({ type: field.type, unsavedValue: undefined }); } else { - onChange(); + onInputChange(); } }; @@ -162,7 +162,7 @@ export const FieldRow = (props: FieldRowProps) => { {unsavedScreenReaderMessage} diff --git a/packages/kbn-management/settings/components/field_row/footer/reset_link.test.tsx b/packages/kbn-management/settings/components/field_row/footer/reset_link.test.tsx index 2704e9e33d743..74f87aadca816 100644 --- a/packages/kbn-management/settings/components/field_row/footer/reset_link.test.tsx +++ b/packages/kbn-management/settings/components/field_row/footer/reset_link.test.tsx @@ -9,13 +9,11 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; -import { SettingType } from '@kbn/management-settings-types'; - import { wrap } from '../mocks'; import { InputResetLink, InputResetLinkProps } from './reset_link'; describe('InputResetLink', () => { - const defaultProps: InputResetLinkProps = { + const defaultProps: InputResetLinkProps = { field: { type: 'string', id: 'test', diff --git a/packages/kbn-management/settings/components/field_row/footer/reset_link.tsx b/packages/kbn-management/settings/components/field_row/footer/reset_link.tsx index 35a34350e0be8..778995085d981 100644 --- a/packages/kbn-management/settings/components/field_row/footer/reset_link.tsx +++ b/packages/kbn-management/settings/components/field_row/footer/reset_link.tsx @@ -21,7 +21,7 @@ import { isFieldDefaultValue } from '@kbn/management-settings-utilities'; /** * Props for a {@link InputResetLink} component. */ -export interface InputResetLinkProps { +export interface InputResetLinkProps { /** The {@link FieldDefinition} corresponding the setting. */ field: Pick< FieldDefinition, diff --git a/packages/kbn-management/settings/components/field_row/index.ts b/packages/kbn-management/settings/components/field_row/index.ts index 98c64f1cd494d..6bcbcf409d1be 100644 --- a/packages/kbn-management/settings/components/field_row/index.ts +++ b/packages/kbn-management/settings/components/field_row/index.ts @@ -6,12 +6,11 @@ * Side Public License, v 1. */ -export { FieldRow, type FieldRowProps as FieldProps } from './field_row'; +export { FieldRow, type FieldRowProps } from './field_row'; export { FieldRowProvider, FieldRowKibanaProvider, type FieldRowProviderProps } from './services'; export type { FieldRowServices, FieldRowKibanaDependencies, - RowOnChangeFn, KibanaDependencies, Services, } from './types'; diff --git a/packages/kbn-management/settings/components/field_row/types.ts b/packages/kbn-management/settings/components/field_row/types.ts index d353cd91fba49..a483763e79571 100644 --- a/packages/kbn-management/settings/components/field_row/types.ts +++ b/packages/kbn-management/settings/components/field_row/types.ts @@ -12,7 +12,6 @@ import type { FieldInputServices, FieldInputKibanaDependencies, } from '@kbn/management-settings-components-field-input'; -import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types'; /** * Contextual services used by a {@link FieldRow} component. @@ -43,13 +42,3 @@ export interface KibanaDependencies { * render a {@link FieldRow} component and its dependents. */ export type FieldRowKibanaDependencies = KibanaDependencies & FieldInputKibanaDependencies; - -/** - * An `onChange` handler for a {@link FieldRow} component. - * @param id A unique id corresponding to the particular setting being changed. - * @param change The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. - */ -export type RowOnChangeFn = ( - id: string, - change?: UnsavedFieldChange -) => void; diff --git a/packages/kbn-management/settings/components/form/form.test.tsx b/packages/kbn-management/settings/components/form/form.test.tsx index 2f1cbdb80bcac..0e149938e2146 100644 --- a/packages/kbn-management/settings/components/form/form.test.tsx +++ b/packages/kbn-management/settings/components/form/form.test.tsx @@ -7,22 +7,20 @@ */ import React from 'react'; -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { FieldDefinition, SettingType } from '@kbn/management-settings-types'; import { getFieldDefinitions } from '@kbn/management-settings-field-definition'; +import { getSettingsMock } from '@kbn/management-settings-utilities/mocks/settings.mock'; +import { TEST_SUBJ_PREFIX_FIELD } from '@kbn/management-settings-components-field-input/input'; import { Form } from './form'; -import { wrap, getSettingsMock, createFormServicesMock, uiSettingsClientMock } from './mocks'; -import { TEST_SUBJ_PREFIX_FIELD } from '@kbn/management-settings-components-field-input/input'; +import { wrap, createFormServicesMock, uiSettingsClientMock } from './mocks'; import { DATA_TEST_SUBJ_SAVE_BUTTON, DATA_TEST_SUBJ_CANCEL_BUTTON } from './bottom_bar/bottom_bar'; import { FormServices } from './types'; const settingsMock = getSettingsMock(); -const fields: Array> = getFieldDefinitions( - settingsMock, - uiSettingsClientMock -); +const fields: FieldDefinition[] = getFieldDefinitions(settingsMock, uiSettingsClientMock); describe('Form', () => { beforeEach(() => { @@ -78,14 +76,18 @@ describe('Form', () => { fireEvent.change(input, { target: { value: 'test' } }); const saveButton = getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON); - fireEvent.click(saveButton); + act(() => { + fireEvent.click(saveButton); + }); - expect(services.saveChanges).toHaveBeenCalledWith({ - string: { type: 'string', unsavedValue: 'test' }, + await waitFor(() => { + expect(services.saveChanges).toHaveBeenCalledWith({ + string: { type: 'string', unsavedValue: 'test' }, + }); }); }); - it('clears changes when Cancel button is clicked', () => { + it('clears changes when Cancel button is clicked', async () => { const { getByTestId } = render(wrap(
)); const testFieldType = 'string'; @@ -93,12 +95,16 @@ describe('Form', () => { fireEvent.change(input, { target: { value: 'test' } }); const cancelButton = getByTestId(DATA_TEST_SUBJ_CANCEL_BUTTON); - fireEvent.click(cancelButton); + act(() => { + fireEvent.click(cancelButton); + }); - expect(input).toHaveValue(settingsMock[testFieldType].value); + await waitFor(() => { + expect(input).toHaveValue(settingsMock[testFieldType].value); + }); }); - it('fires showError when saving is unsuccessful', () => { + it('fires showError when saving is unsuccessful', async () => { const services: FormServices = createFormServicesMock(); const saveChangesWithError = jest.fn(() => { throw new Error('Unable to save'); @@ -114,15 +120,19 @@ describe('Form', () => { fireEvent.change(input, { target: { value: 'test' } }); const saveButton = getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON); - fireEvent.click(saveButton); + act(() => { + fireEvent.click(saveButton); + }); - expect(testServices.showError).toHaveBeenCalled(); + await waitFor(() => { + expect(testServices.showError).toHaveBeenCalled(); + }); }); it('fires showReloadPagePrompt when changing a reloadPageRequired setting', async () => { const services: FormServices = createFormServicesMock(); // Make all settings require a page reload - const testFields: Array> = getFieldDefinitions( + const testFields: FieldDefinition[] = getFieldDefinitions( getSettingsMock(true), uiSettingsClientMock ); @@ -135,7 +145,9 @@ describe('Form', () => { fireEvent.change(input, { target: { value: 'test' } }); const saveButton = getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON); - fireEvent.click(saveButton); + act(() => { + fireEvent.click(saveButton); + }); await waitFor(() => { expect(services.showReloadPagePrompt).toHaveBeenCalled(); diff --git a/packages/kbn-management/settings/components/form/form.tsx b/packages/kbn-management/settings/components/form/form.tsx index fabc80755cad8..0bc9d2c96fb29 100644 --- a/packages/kbn-management/settings/components/form/form.tsx +++ b/packages/kbn-management/settings/components/form/form.tsx @@ -9,9 +9,10 @@ import React, { Fragment } from 'react'; import type { FieldDefinition } from '@kbn/management-settings-types'; -import { FieldRow, RowOnChangeFn } from '@kbn/management-settings-components-field-row'; -import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types'; +import { FieldCategories } from '@kbn/management-settings-components-field-category'; +import { UnsavedFieldChange, OnFieldChangeFn } from '@kbn/management-settings-types'; import { isEmpty } from 'lodash'; +import { categorizeFields } from '@kbn/management-settings-utilities'; import { BottomBar } from './bottom_bar'; import { useSave } from './use_save'; @@ -20,7 +21,7 @@ import { useSave } from './use_save'; */ export interface FormProps { /** A list of {@link FieldDefinition} corresponding to settings to be displayed in the form. */ - fields: Array>; + fields: FieldDefinition[]; /** True if saving settings is enabled, false otherwise. */ isSavingEnabled: boolean; } @@ -32,9 +33,9 @@ export interface FormProps { export const Form = (props: FormProps) => { const { fields, isSavingEnabled } = props; - const [unsavedChanges, setUnsavedChanges] = React.useState< - Record> - >({}); + const [unsavedChanges, setUnsavedChanges] = React.useState>( + {} + ); const [isLoading, setIsLoading] = React.useState(false); @@ -53,7 +54,7 @@ export const Form = (props: FormProps) => { setIsLoading(false); }; - const onChange: RowOnChangeFn = (id, change) => { + const onFieldChange: OnFieldChangeFn = (id, change) => { if (!change) { const { [id]: unsavedChange, ...rest } = unsavedChanges; setUnsavedChanges(rest); @@ -63,15 +64,16 @@ export const Form = (props: FormProps) => { setUnsavedChanges((changes) => ({ ...changes, [id]: change })); }; - const fieldRows = fields.map((field) => { - const { id: key } = field; - const unsavedChange = unsavedChanges[key]; - return ; - }); + const categorizedFields = categorizeFields(fields); + + /** TODO - Querying is not enabled yet. */ + const onClearQuery = () => {}; return ( -
{fieldRows}
+ {!isEmpty(unsavedChanges) && ( { return ( - {children} + {children} ); }; @@ -47,7 +47,7 @@ export const FormKibanaProvider: FC = ({ children, ...de return ( >) => { + saveChanges: (changes: Record) => { const arr = Object.entries(changes).map(([key, value]) => settings.client.set(key, value.unsavedValue) ); @@ -57,7 +57,9 @@ export const FormKibanaProvider: FC = ({ children, ...de showReloadPagePrompt: () => toasts.add(reloadPageToast(theme, i18nStart)), }} > - {children} + + {children} + ); }; diff --git a/packages/kbn-management/settings/components/form/storybook/form.stories.tsx b/packages/kbn-management/settings/components/form/storybook/form.stories.tsx index 5ba4a57d8a2f3..060ddd6355340 100644 --- a/packages/kbn-management/settings/components/form/storybook/form.stories.tsx +++ b/packages/kbn-management/settings/components/form/storybook/form.stories.tsx @@ -6,12 +6,13 @@ * Side Public License, v 1. */ import React from 'react'; -import { EuiPanel } from '@elastic/eui'; import { action } from '@storybook/addon-actions'; import { ComponentMeta } from '@storybook/react'; -import { FieldDefinition, SettingType } from '@kbn/management-settings-types'; +import { FieldDefinition } from '@kbn/management-settings-types'; import { getFieldDefinitions } from '@kbn/management-settings-field-definition'; -import { getSettingsMock, uiSettingsClientMock } from '../mocks'; +import { getSettingsMock } from '@kbn/management-settings-utilities/mocks/settings.mock'; + +import { uiSettingsClientMock } from '../mocks'; import { Form as Component } from '../form'; import { FormProvider } from '../services'; @@ -37,12 +38,15 @@ export default { showError={action('showError')} showReloadPagePrompt={action('showReloadPagePrompt')} > - - - + ), ], + parameters: { + backgrounds: { + default: 'ghost', + }, + }, } as ComponentMeta; interface FormStoryProps { @@ -53,7 +57,7 @@ interface FormStoryProps { } export const Form = ({ isSavingEnabled, requirePageReload }: FormStoryProps) => { - const fields: Array> = getFieldDefinitions( + const fields: FieldDefinition[] = getFieldDefinitions( getSettingsMock(requirePageReload), uiSettingsClientMock ); diff --git a/packages/kbn-management/settings/components/form/tsconfig.json b/packages/kbn-management/settings/components/form/tsconfig.json index 359e5560fd2e4..cfd8fe3ac53b0 100644 --- a/packages/kbn-management/settings/components/form/tsconfig.json +++ b/packages/kbn-management/settings/components/form/tsconfig.json @@ -30,5 +30,7 @@ "@kbn/core-theme-browser", "@kbn/core-ui-settings-browser", "@kbn/management-settings-components-field-input", + "@kbn/management-settings-components-field-category", + "@kbn/management-settings-utilities", ] } diff --git a/packages/kbn-management/settings/components/form/types.ts b/packages/kbn-management/settings/components/form/types.ts index 2e803cbd74cb5..155420f5effe2 100644 --- a/packages/kbn-management/settings/components/form/types.ts +++ b/packages/kbn-management/settings/components/form/types.ts @@ -10,7 +10,7 @@ import type { FieldRowKibanaDependencies, FieldRowServices, } from '@kbn/management-settings-components-field-row'; -import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types'; +import { UnsavedFieldChange } from '@kbn/management-settings-types'; import { SettingsStart } from '@kbn/core-ui-settings-browser'; import { I18nStart } from '@kbn/core-i18n-browser'; import { ThemeServiceStart } from '@kbn/core-theme-browser'; @@ -20,7 +20,7 @@ import { ToastsStart } from '@kbn/core-notifications-browser'; * Contextual services used by a {@link Form} component. */ export interface Services { - saveChanges: (changes: Record>) => void; + saveChanges: (changes: Record) => void; showError: (message: string) => void; showReloadPagePrompt: () => void; } diff --git a/packages/kbn-management/settings/components/form/use_save.ts b/packages/kbn-management/settings/components/form/use_save.ts index ebd1981eb57d9..f0679f31aca20 100644 --- a/packages/kbn-management/settings/components/form/use_save.ts +++ b/packages/kbn-management/settings/components/form/use_save.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import type { FieldDefinition, SettingType } from '@kbn/management-settings-types'; +import type { FieldDefinition } from '@kbn/management-settings-types'; import { isEmpty } from 'lodash'; import { i18n } from '@kbn/i18n'; import { UnsavedFieldChange } from '@kbn/management-settings-types'; @@ -14,7 +14,7 @@ import { useServices } from './services'; export interface UseSaveParameters { /** All {@link FieldDefinition} in the form. */ - fields: Array>; + fields: FieldDefinition[]; /** The function to invoke for clearing all unsaved changes. */ clearChanges: () => void; } @@ -28,7 +28,7 @@ export interface UseSaveParameters { export const useSave = (params: UseSaveParameters) => { const { saveChanges, showError, showReloadPagePrompt } = useServices(); - return async (changes: Record>) => { + return async (changes: Record) => { if (isEmpty(changes)) { return; } diff --git a/packages/kbn-management/settings/field_definition/get_definitions.ts b/packages/kbn-management/settings/field_definition/get_definitions.ts index 83d604db294cf..e84c05b03f07d 100644 --- a/packages/kbn-management/settings/field_definition/get_definitions.ts +++ b/packages/kbn-management/settings/field_definition/get_definitions.ts @@ -7,7 +7,7 @@ */ import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; -import { FieldDefinition, SettingType, UiSettingMetadata } from '@kbn/management-settings-types'; +import { FieldDefinition, UiSettingMetadata } from '@kbn/management-settings-types'; import { getFieldDefinition } from './get_definition'; type SettingsClient = Pick; @@ -21,9 +21,9 @@ type SettingsClient = Pick; * @returns An array of {@link FieldDefinition} objects. */ export const getFieldDefinitions = ( - settings: Record>, + settings: Record, client: SettingsClient -): Array> => +): FieldDefinition[] => Object.entries(settings).map(([id, setting]) => getFieldDefinition({ id, diff --git a/packages/kbn-management/settings/field_definition/is/field_definition.ts b/packages/kbn-management/settings/field_definition/is/field_definition.ts index 52c6e83468177..364cb4940f0d2 100644 --- a/packages/kbn-management/settings/field_definition/is/field_definition.ts +++ b/packages/kbn-management/settings/field_definition/is/field_definition.ts @@ -26,13 +26,12 @@ import { MarkdownFieldDefinition, NumberFieldDefinition, SelectFieldDefinition, - SettingType, StringFieldDefinition, UndefinedFieldDefinition, } from '@kbn/management-settings-types'; /** Simplifed type for a {@link FieldDefinition} */ -type Definition = Pick, 'type'>; +type Definition = Pick; /** * Returns `true` if the given {@link FieldDefinition} is an {@link ArrayFieldDefinition}, diff --git a/packages/kbn-management/settings/field_definition/is/unsaved_change.ts b/packages/kbn-management/settings/field_definition/is/unsaved_change.ts index 6af63db17e36a..a88e0842bb095 100644 --- a/packages/kbn-management/settings/field_definition/is/unsaved_change.ts +++ b/packages/kbn-management/settings/field_definition/is/unsaved_change.ts @@ -27,12 +27,11 @@ import { SelectUnsavedFieldChange, StringUnsavedFieldChange, UndefinedUnsavedFieldChange, - SettingType, UnsavedFieldChange, } from '@kbn/management-settings-types'; /** Simplifed type for a {@link UnsavedFieldChange} */ -type Change = UnsavedFieldChange; +type Change = UnsavedFieldChange; /** * Returns `true` if the given {@link FieldUnsavedChange} is an {@link ArrayUnsavedFieldChange}, diff --git a/packages/kbn-management/settings/types/category.ts b/packages/kbn-management/settings/types/category.ts new file mode 100644 index 0000000000000..fe5d4910e426f --- /dev/null +++ b/packages/kbn-management/settings/types/category.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 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 { FieldDefinition } from './field_definition'; + +export interface CategorizedFields { + [category: string]: { + count: number; + fields: FieldDefinition[]; + }; +} diff --git a/packages/kbn-management/settings/types/field_definition.ts b/packages/kbn-management/settings/types/field_definition.ts index eb34df3b67868..2da9168b6401e 100644 --- a/packages/kbn-management/settings/types/field_definition.ts +++ b/packages/kbn-management/settings/types/field_definition.ts @@ -20,7 +20,10 @@ import { KnownTypeToValue, SettingType } from './setting_type'; * representing a UiSetting). * @public */ -export interface FieldDefinition | null> { +export interface FieldDefinition< + T extends SettingType = SettingType, + V = KnownTypeToValue | null +> { /** UX ARIA attributes derived from the setting. */ ariaAttributes: { /** The `aria-label` attribute for the field input. */ diff --git a/packages/kbn-management/settings/types/index.ts b/packages/kbn-management/settings/types/index.ts index 08cd1ae1df3bb..7f8afb8073a5c 100644 --- a/packages/kbn-management/settings/types/index.ts +++ b/packages/kbn-management/settings/types/index.ts @@ -51,6 +51,7 @@ export type { StringUnsavedFieldChange, UndefinedUnsavedFieldChange, UnsavedFieldChange, + UnsavedFieldChanges, } from './unsaved_change'; export type { @@ -64,6 +65,8 @@ export type { Value, } from './setting_type'; +export type { CategorizedFields } from './category'; + /** * A React `ref` that indicates an input can be reset using an * imperative handle. @@ -76,4 +79,16 @@ export type ResetInputRef = { * A function that is called when the value of a {@link FieldInput} changes. * @param change The {@link UnsavedFieldChange} passed to the handler. */ -export type OnChangeFn = (change?: UnsavedFieldChange) => void; +export type OnInputChangeFn = ( + change?: UnsavedFieldChange +) => void; + +/** + * An `onFieldChange` handler when a Field changes. + * @param id A unique id corresponding to the particular setting being changed. + * @param change The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. + */ +export type OnFieldChangeFn = ( + id: string, + change?: UnsavedFieldChange +) => void; diff --git a/packages/kbn-management/settings/types/metadata.ts b/packages/kbn-management/settings/types/metadata.ts index c0a79549039de..48ff816230ce1 100644 --- a/packages/kbn-management/settings/types/metadata.ts +++ b/packages/kbn-management/settings/types/metadata.ts @@ -21,8 +21,10 @@ export type UiSetting = PublicUiSettingsParams & UserProvidedValues; * * @public */ -export interface UiSettingMetadata | null> - extends UiSetting { +export interface UiSettingMetadata< + T extends SettingType = SettingType, + V = KnownTypeToValue | null +> extends UiSetting { /** * The type of setting being represented. * @see{@link SettingType} diff --git a/packages/kbn-management/settings/types/setting_type.ts b/packages/kbn-management/settings/types/setting_type.ts index da297c6d94171..92db0744ac83e 100644 --- a/packages/kbn-management/settings/types/setting_type.ts +++ b/packages/kbn-management/settings/types/setting_type.ts @@ -63,7 +63,7 @@ export type Value = string | boolean | number | Array | undefin * given {@link SettingType}. * @public */ -export type KnownTypeToValue = +export type KnownTypeToValue = T extends 'color' | 'image' | 'json' | 'markdown' | 'select' | 'string' ? string : T extends 'boolean' ? boolean : T extends 'number' | 'bigint' ? number : diff --git a/packages/kbn-management/settings/types/unsaved_change.ts b/packages/kbn-management/settings/types/unsaved_change.ts index 3bd815187f70a..0b5a02e241931 100644 --- a/packages/kbn-management/settings/types/unsaved_change.ts +++ b/packages/kbn-management/settings/types/unsaved_change.ts @@ -13,7 +13,7 @@ import { KnownTypeToValue, SettingType } from './setting_type'; * yet been saved. * @public */ -export interface UnsavedFieldChange { +export interface UnsavedFieldChange { /** * The type of setting. * @see {@link SettingType} @@ -125,3 +125,5 @@ export type KnownTypeToUnsavedChange = T extends 'string' ? StringUnsavedFieldChange: T extends 'undefined' ? UndefinedUnsavedFieldChange : never; + +export type UnsavedFieldChanges = Record; diff --git a/packages/kbn-management/settings/utilities/category/categorize_fields.ts b/packages/kbn-management/settings/utilities/category/categorize_fields.ts new file mode 100644 index 0000000000000..a705d5f872266 --- /dev/null +++ b/packages/kbn-management/settings/utilities/category/categorize_fields.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CategorizedFields, FieldDefinition } from '@kbn/management-settings-types'; + +export const categorizeFields = (fields: FieldDefinition[]): CategorizedFields => { + // Group settings by category + return fields.reduce((grouped: CategorizedFields, field) => { + const category = field.categories[0]; + const group = grouped[category] || { count: 0, fields: [] }; + group.fields = [...group.fields, field]; + group.count = group.fields.length; + grouped[category] = group; + + return grouped; + }, {}); +}; diff --git a/packages/kbn-management/settings/utilities/category/get_category_name.ts b/packages/kbn-management/settings/utilities/category/get_category_name.ts new file mode 100644 index 0000000000000..8355f001f7bbd --- /dev/null +++ b/packages/kbn-management/settings/utilities/category/get_category_name.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +const upperFirst = (str = '') => str.replace(/^./, (strng) => strng.toUpperCase()); + +const names: Record = { + general: i18n.translate('management.settings.categoryNames.generalLabel', { + defaultMessage: 'General', + }), + machineLearning: i18n.translate('management.settings.categoryNames.machineLearningLabel', { + defaultMessage: 'Machine Learning', + }), + observability: i18n.translate('management.settings.categoryNames.observabilityLabel', { + defaultMessage: 'Observability', + }), + timelion: i18n.translate('management.settings.categoryNames.timelionLabel', { + defaultMessage: 'Timelion', + }), + notifications: i18n.translate('management.settings.categoryNames.notificationsLabel', { + defaultMessage: 'Notifications', + }), + visualizations: i18n.translate('management.settings.categoryNames.visualizationsLabel', { + defaultMessage: 'Visualizations', + }), + discover: i18n.translate('management.settings.categoryNames.discoverLabel', { + defaultMessage: 'Discover', + }), + dashboard: i18n.translate('management.settings.categoryNames.dashboardLabel', { + defaultMessage: 'Dashboard', + }), + reporting: i18n.translate('management.settings.categoryNames.reportingLabel', { + defaultMessage: 'Reporting', + }), + search: i18n.translate('management.settings.categoryNames.searchLabel', { + defaultMessage: 'Search', + }), + securitySolution: i18n.translate('management.settings.categoryNames.securitySolutionLabel', { + defaultMessage: 'Security Solution', + }), + enterpriseSearch: i18n.translate('management.settings.categoryNames.enterpriseSearchLabel', { + defaultMessage: 'Enterprise Search', + }), +}; + +export function getCategoryName(category?: string) { + return category ? names[category] || upperFirst(category) : ''; +} diff --git a/packages/kbn-management/settings/utilities/category/index.ts b/packages/kbn-management/settings/utilities/category/index.ts new file mode 100644 index 0000000000000..c562a888e0cbe --- /dev/null +++ b/packages/kbn-management/settings/utilities/category/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { categorizeFields } from './categorize_fields'; +export { getCategoryName } from './get_category_name'; diff --git a/packages/kbn-management/settings/utilities/field/use_update.ts b/packages/kbn-management/settings/utilities/field/use_update.ts index 4744d59dd90e7..c330ff25d7f4d 100644 --- a/packages/kbn-management/settings/utilities/field/use_update.ts +++ b/packages/kbn-management/settings/utilities/field/use_update.ts @@ -6,31 +6,33 @@ * Side Public License, v 1. */ -import type { FieldDefinition, SettingType, OnChangeFn } from '@kbn/management-settings-types'; +import type { FieldDefinition, SettingType, OnInputChangeFn } from '@kbn/management-settings-types'; import { hasUnsavedChange } from './has_unsaved_change'; export interface UseUpdateParameters { - /** The {@link OnChangeFn} to invoke. */ - onChange: OnChangeFn; + /** The {@link OnInputChangeFn} to invoke. */ + onInputChange: OnInputChangeFn; /** The {@link FieldDefinition} to use to create an update. */ field: Pick, 'defaultValue' | 'savedValue'>; } /** - * Hook to provide a standard {@link OnChangeFn} that will send an update to the + * Hook to provide a standard {@link OnInputChangeFn} that will send an update to the * field. * * @param params The {@link UseUpdateParameters} to use. - * @returns An {@link OnChangeFn} that will send an update to the field. + * @returns An {@link OnInputChangeFn} that will send an update to the field. */ -export const useUpdate = (params: UseUpdateParameters): OnChangeFn => { - const { onChange, field } = params; +export const useUpdate = ( + params: UseUpdateParameters +): OnInputChangeFn => { + const { onInputChange, field } = params; return (update) => { if (hasUnsavedChange(field, update)) { - onChange(update); + onInputChange(update); } else { - onChange(); + onInputChange(); } }; }; diff --git a/packages/kbn-management/settings/utilities/index.ts b/packages/kbn-management/settings/utilities/index.ts index 4e4523f66eb59..60cb17e47206f 100644 --- a/packages/kbn-management/settings/utilities/index.ts +++ b/packages/kbn-management/settings/utilities/index.ts @@ -7,6 +7,7 @@ */ export { isSettingDefaultValue, normalizeSettings } from './setting'; + export { getFieldInputValue, hasUnsavedChange, @@ -14,3 +15,5 @@ export { useUpdate, type UseUpdateParameters, } from './field'; + +export { categorizeFields, getCategoryName } from './category'; diff --git a/packages/kbn-management/settings/components/form/mocks/settings.ts b/packages/kbn-management/settings/utilities/mocks/settings.mock.ts similarity index 79% rename from packages/kbn-management/settings/components/form/mocks/settings.ts rename to packages/kbn-management/settings/utilities/mocks/settings.mock.ts index e22f24e4a1a09..ca2a2892836ce 100644 --- a/packages/kbn-management/settings/components/form/mocks/settings.ts +++ b/packages/kbn-management/settings/utilities/mocks/settings.mock.ts @@ -14,13 +14,15 @@ type Settings = { /** * A utility function returning a representative set of UiSettings. - * @param requirePageReload The value of the `requirePageReload` param for all settings. + * @param requiresPageReload The value of the `requirePageReload` param for all settings. */ -export const getSettingsMock = (requirePageReload: boolean = false): Settings => { +export const getSettingsMock = ( + requiresPageReload: boolean = false, + readonly: boolean = false +): Settings => { const defaults = { - requiresPageReload: requirePageReload, - readonly: false, - category: ['category'], + requiresPageReload, + readonly, }; return { @@ -29,7 +31,8 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings => name: 'array:test:setting', type: 'array', userValue: null, - value: ['example_value'], + value: ['foo', 'bar', 'baz'], + category: ['general', 'dashboard'], ...defaults, }, boolean: { @@ -38,6 +41,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings => type: 'boolean', userValue: null, value: true, + category: ['general', 'dashboard'], ...defaults, }, color: { @@ -46,6 +50,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings => type: 'color', userValue: null, value: '#FF00CC', + category: ['general', 'dashboard'], ...defaults, }, image: { @@ -54,6 +59,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings => type: 'image', userValue: null, value: '', + category: ['dashboard', 'discover'], ...defaults, }, number: { @@ -62,6 +68,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings => type: 'number', userValue: null, value: 1, + category: ['dashboard', 'discover'], ...defaults, }, json: { @@ -70,6 +77,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings => type: 'json', userValue: null, value: '{"foo": "bar"}', + category: ['dashboard', 'discover'], ...defaults, }, markdown: { @@ -78,6 +86,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings => type: 'markdown', userValue: null, value: '', + category: ['notifications', 'search'], ...defaults, }, select: { @@ -92,6 +101,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings => type: 'select', userValue: null, value: 'apple', + category: ['notifications', 'search'], ...defaults, }, string: { @@ -100,6 +110,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings => type: 'string', userValue: null, value: 'hello world', + category: ['notifications', 'search'], ...defaults, }, undefined: { @@ -108,6 +119,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings => type: 'undefined', userValue: null, value: undefined, + category: ['notifications', 'search'], ...defaults, }, }; diff --git a/packages/kbn-management/settings/utilities/setting/is_default_value.ts b/packages/kbn-management/settings/utilities/setting/is_default_value.ts index b59467b7410ac..9c6dbe0077521 100644 --- a/packages/kbn-management/settings/utilities/setting/is_default_value.ts +++ b/packages/kbn-management/settings/utilities/setting/is_default_value.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SettingType, UiSettingMetadata, Value } from '@kbn/management-settings-types'; +import { UiSettingMetadata, Value } from '@kbn/management-settings-types'; import isEqual from 'lodash/isEqual'; /** @@ -17,7 +17,7 @@ import isEqual from 'lodash/isEqual'; * @returns True if the provided value is equal to the setting's default value, false otherwise. */ export const isSettingDefaultValue = ( - setting: UiSettingMetadata, + setting: UiSettingMetadata, userValue: Value = setting.userValue ) => { const { value } = setting; diff --git a/packages/kbn-management/settings/utilities/setting/normalize_settings.ts b/packages/kbn-management/settings/utilities/setting/normalize_settings.ts index fa247151c7751..e4083ab58c96b 100644 --- a/packages/kbn-management/settings/utilities/setting/normalize_settings.ts +++ b/packages/kbn-management/settings/utilities/setting/normalize_settings.ts @@ -92,10 +92,8 @@ const deriveValue = (type: SettingType, value: unknown): Value => { * may be missing the `type` or `value` properties. * @returns A mapped collection of normalized {@link UiSetting} objects. */ -export const normalizeSettings = ( - rawSettings: RawSettings -): Record> => { - const normalizedSettings: Record> = {}; +export const normalizeSettings = (rawSettings: RawSettings): Record => { + const normalizedSettings: Record = {}; const entries = Object.entries(rawSettings); diff --git a/packages/kbn-management/settings/utilities/tsconfig.json b/packages/kbn-management/settings/utilities/tsconfig.json index 1247d2cd18707..c97a0448689a3 100644 --- a/packages/kbn-management/settings/utilities/tsconfig.json +++ b/packages/kbn-management/settings/utilities/tsconfig.json @@ -17,5 +17,6 @@ ], "kbn_references": [ "@kbn/management-settings-types", + "@kbn/i18n", ] } diff --git a/packages/kbn-management/storybook/config/preview.ts b/packages/kbn-management/storybook/config/preview.ts index ee65b88614fb9..032af90b7dd5b 100644 --- a/packages/kbn-management/storybook/config/preview.ts +++ b/packages/kbn-management/storybook/config/preview.ts @@ -20,3 +20,19 @@ import jest from 'jest-mock'; /* @ts-expect-error TS doesn't see jest as a property of window, and I don't want to edit our global config. */ window.jest = jest; + +export const parameters = { + backgrounds: { + default: 'body', + values: [ + { + name: 'body', + value: '##f7f8fc', + }, + { + name: 'ghost', + value: '#fff', + }, + ], + }, +}; diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 9c7766f4c0226..c319208456963 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -4,6 +4,7 @@ pageLoadAssetSize: aiops: 10000 alerting: 106936 apm: 64385 + assetManager: 25000 banners: 17946 bfetch: 22837 canvas: 1066647 diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index f036c94659d26..ec5fd0ca2add5 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -106,7 +106,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ingest-agent-policies": "f11cc19275f4c3e4ee7c5cd6423b6706b21b989d", "ingest-download-sources": "279a68147e62e4d8858c09ad1cf03bd5551ce58d", "ingest-outputs": "b4e636b13a5d0f89f0400fb67811d4cca4736eb0", - "ingest-package-policies": "af9e8d523a6f3ae5b8c9adcfba391ff405dfa374", + "ingest-package-policies": "8ec637429836f80f1fcc798bcee7c5916eceaed5", "ingest_manager_settings": "64955ef1b7a9ffa894d4bb9cf863b5602bfa6885", "inventory-view": "b8683c8e352a286b4aca1ab21003115a4800af83", "kql-telemetry": "93c1d16c1a0dfca9c8842062cf5ef8f62ae401ad", @@ -138,7 +138,7 @@ describe('checking migration metadata changes on all registered SO types', () => "security-rule": "07abb4d7e707d91675ec0495c73816394c7b521f", "security-solution-signals-migration": "9d99715fe5246f19de2273ba77debd2446c36bb1", "siem-detection-engine-rule-actions": "54f08e23887b20da7c805fab7c60bc67c428aff9", - "siem-ui-timeline": "820b5a7c478cd4d5ae9cd92ce0d05ac988fee69c", + "siem-ui-timeline": "2d9925f7286a9e947a008eff8e61118dadd8229b", "siem-ui-timeline-note": "0a32fb776907f596bedca292b8c646496ae9c57b", "siem-ui-timeline-pinned-event": "082daa3ce647b33873f6abccf340bdfa32057c8d", "slo": "2048ab6791df2e1ae0936f29c20765cb8d2fcfaa", diff --git a/src/plugins/discover/public/application/main/services/discover_app_state_container.test.ts b/src/plugins/discover/public/application/main/services/discover_app_state_container.test.ts index 80fb6c241b2f5..f93a9fd759da9 100644 --- a/src/plugins/discover/public/application/main/services/discover_app_state_container.test.ts +++ b/src/plugins/discover/public/application/main/services/discover_app_state_container.test.ts @@ -6,7 +6,10 @@ * Side Public License, v 1. */ +import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; +import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public'; +import type { Filter } from '@kbn/es-query'; import { History } from 'history'; import { savedSearchMock } from '../../../__mocks__/saved_search'; @@ -15,6 +18,7 @@ import { DiscoverAppStateContainer, getDiscoverAppStateContainer, } from './discover_app_state_container'; +import { SavedSearch } from '@kbn/saved-search-plugin/common'; let history: History; let state: DiscoverAppStateContainer; @@ -48,4 +52,109 @@ describe('Test discover app state container', () => { state.set({ index: 'second' }); expect(state.getPrevious()).toEqual(stateA); }); + + describe('getAppStateFromSavedSearch', () => { + const customQuery = { + language: 'kuery', + query: '_id: *', + }; + + const defaultQuery = { + query: '*', + language: 'kuery', + }; + + const customFilter = { + $state: { + store: 'appState', + }, + meta: { + alias: null, + disabled: false, + field: 'ecs.version', + index: 'kibana-event-log-data-view', + key: 'ecs.version', + negate: false, + params: { + query: '1.8.0', + }, + type: 'phrase', + }, + query: { + match_phrase: { + 'ecs.version': '1.8.0', + }, + }, + } as Filter; + + const localSavedSearchMock = { + id: 'the-saved-search-id', + title: 'A saved search', + breakdownField: 'customBreakDownField', + searchSource: createSearchSourceMock({ + index: dataViewMock, + filter: [customFilter], + query: customQuery, + }), + hideChart: true, + rowsPerPage: 250, + hideAggregatedPreview: true, + } as SavedSearch; + + test('should return correct output', () => { + const appState = state.getAppStateFromSavedSearch(localSavedSearchMock); + expect(appState).toMatchObject( + expect.objectContaining({ + breakdownField: 'customBreakDownField', + columns: ['default_column'], + filters: [customFilter], + grid: undefined, + hideChart: true, + index: 'the-data-view-id', + interval: 'auto', + query: customQuery, + rowHeight: undefined, + rowsPerPage: 250, + hideAggregatedPreview: true, + savedQuery: undefined, + sort: [], + viewMode: undefined, + }) + ); + }); + + test('should return default query if query is undefined', () => { + discoverServiceMock.data.query.queryString.getDefaultQuery = jest + .fn() + .mockReturnValue(defaultQuery); + const newSavedSearchMock = { + id: 'new-saved-search-id', + title: 'A saved search', + searchSource: createSearchSourceMock({ + index: dataViewMock, + filter: [customFilter], + query: undefined, + }), + }; + const appState = state.getAppStateFromSavedSearch(newSavedSearchMock); + expect(appState).toMatchObject( + expect.objectContaining({ + breakdownField: undefined, + columns: ['default_column'], + filters: [customFilter], + grid: undefined, + hideChart: undefined, + index: 'the-data-view-id', + interval: 'auto', + query: defaultQuery, + rowHeight: undefined, + rowsPerPage: undefined, + hideAggregatedPreview: undefined, + savedQuery: undefined, + sort: [], + viewMode: undefined, + }) + ); + }); + }); }); diff --git a/src/plugins/discover/public/application/main/services/discover_app_state_container.ts b/src/plugins/discover/public/application/main/services/discover_app_state_container.ts index 47cd216b1547e..046e8fd6393f1 100644 --- a/src/plugins/discover/public/application/main/services/discover_app_state_container.ts +++ b/src/plugins/discover/public/application/main/services/discover_app_state_container.ts @@ -73,6 +73,12 @@ export interface DiscoverAppStateContainer extends ReduxLikeStateContainer void; + + /* + * Get updated AppState when given a saved search + * + * */ + getAppStateFromSavedSearch: (newSavedSearch: SavedSearch) => DiscoverAppState; } export interface DiscoverAppState { @@ -170,6 +176,13 @@ export const getDiscoverAppStateContainer = ({ return !isEqualState(initialState, appStateContainer.getState()); }; + const getAppStateFromSavedSearch = (newSavedSearch: SavedSearch) => { + return getStateDefaults({ + savedSearch: newSavedSearch, + services, + }); + }; + const resetToState = (state: DiscoverAppState) => { addLog('[appState] reset state to', state); previousState = state; @@ -260,6 +273,7 @@ export const getDiscoverAppStateContainer = ({ replaceUrlState, syncState: startAppStateUrlSync, update, + getAppStateFromSavedSearch, }; }; diff --git a/src/plugins/discover/public/application/main/services/discover_state.ts b/src/plugins/discover/public/application/main/services/discover_state.ts index d6e2809c14bd6..bb0fbed792d64 100644 --- a/src/plugins/discover/public/application/main/services/discover_state.ts +++ b/src/plugins/discover/public/application/main/services/discover_state.ts @@ -50,7 +50,10 @@ import { DiscoverSavedSearchContainer, } from './discover_saved_search_container'; import { updateFiltersReferences } from '../utils/update_filter_references'; -import { getDiscoverGlobalStateContainer } from './discover_global_state_container'; +import { + getDiscoverGlobalStateContainer, + DiscoverGlobalStateContainer, +} from './discover_global_state_container'; interface DiscoverStateContainerParams { /** * Browser history @@ -87,6 +90,11 @@ export interface LoadParams { } export interface DiscoverStateContainer { + /** + * Global State, the _g part of the URL + */ + globalState: DiscoverGlobalStateContainer; + /** * App state, the _a part of the URL */ @@ -460,6 +468,7 @@ export function getDiscoverStateContainer({ }; return { + globalState: globalStateContainer, appState: appStateContainer, internalState: internalStateContainer, dataState: dataStateContainer, diff --git a/src/plugins/discover/public/mocks.tsx b/src/plugins/discover/public/mocks.tsx index ae2bc4fa547fd..e97c8f5a841a2 100644 --- a/src/plugins/discover/public/mocks.tsx +++ b/src/plugins/discover/public/mocks.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { DiscoverSetup, DiscoverStart } from '.'; +import { getDiscoverStateMock } from './__mocks__/discover_state.mock'; export type Setup = jest.Mocked; export type Start = jest.Mocked; @@ -32,4 +33,5 @@ const createStartContract = (): Start => { export const discoverPluginMock = { createSetupContract, createStartContract, + getDiscoverStateMock, }; diff --git a/src/plugins/saved_objects_tagging_oss/common/types.ts b/src/plugins/saved_objects_tagging_oss/common/types.ts index e62639659b5f4..046e187a6b9d7 100644 --- a/src/plugins/saved_objects_tagging_oss/common/types.ts +++ b/src/plugins/saved_objects_tagging_oss/common/types.ts @@ -35,6 +35,7 @@ export interface ITagsClient { create(attributes: TagAttributes, options?: CreateTagOptions): Promise; get(id: string): Promise; getAll(options?: GetAllTagsOptions): Promise; + findByName(name: string, options?: { exact?: boolean }): Promise; delete(id: string): Promise; update(id: string, attributes: TagAttributes): Promise; } diff --git a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts index e96aa2277b0a5..f481319ec7fb0 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts @@ -16,6 +16,7 @@ const createClientMock = () => { getAll: jest.fn(), delete: jest.fn(), update: jest.fn(), + findByName: jest.fn(), }; return mock; diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 94af20ff4f86b..ca639ed3272fd 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -197,6 +197,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.apm.featureFlags.sourcemapApiAvailable (any)', 'xpack.apm.featureFlags.storageExplorerAvailable (any)', 'xpack.apm.serverless.enabled (any)', // It's a boolean (any because schema.conditional) + 'xpack.assetManager.alphaEnabled (boolean)', 'xpack.observability_onboarding.serverless.enabled (any)', // It's a boolean (any because schema.conditional) 'xpack.cases.files.allowedMimeTypes (array)', 'xpack.cases.files.maxSize (number)', diff --git a/tsconfig.base.json b/tsconfig.base.json index 3f35dada018c5..2ea42b377596c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -964,6 +964,8 @@ "@kbn/management-cards-navigation/*": ["packages/kbn-management/cards_navigation/*"], "@kbn/management-plugin": ["src/plugins/management"], "@kbn/management-plugin/*": ["src/plugins/management/*"], + "@kbn/management-settings-components-field-category": ["packages/kbn-management/settings/components/field_category"], + "@kbn/management-settings-components-field-category/*": ["packages/kbn-management/settings/components/field_category/*"], "@kbn/management-settings-components-field-input": ["packages/kbn-management/settings/components/field_input"], "@kbn/management-settings-components-field-input/*": ["packages/kbn-management/settings/components/field_input/*"], "@kbn/management-settings-components-field-row": ["packages/kbn-management/settings/components/field_row"], diff --git a/x-pack/plugins/asset_manager/README.md b/x-pack/plugins/asset_manager/README.md index f82f174af471c..d73bfbb53b087 100644 --- a/x-pack/plugins/asset_manager/README.md +++ b/x-pack/plugins/asset_manager/README.md @@ -1,39 +1,13 @@ # Asset Manager Plugin -This plugin provides access to the asset data stored in assets-\* indices, primarily -for inventory and topology purposes. +This plugin provides access to observed asset data, such as information about hosts, pods, containers, services, and more. ## Documentation -See [docs for the provided APIs in the docs folder](./docs/index.md). +### User Docs -## Running Tests +For those interested in making use of the APIs provided by this plugin, see [our API docs](./docs/api.md). -There are integration tests for the endpoints implemented thus far as well as for -the sample data tests. There is also a small set of tests meant to ensure that the -plugin is not doing anything without the proper config value in place to enable -the plugin fully. For more on enabling the plugin, see [the docs page](./docs/index.md). +### Developer Docs -The "not enabled" tests are run by default in CI. To run them manually, do the following: - -```shell -$ node scripts/functional_tests_server --config x-pack/test/api_integration/apis/asset_manager/config_when_disabled.ts -$ node scripts/functional_test_runner --config=x-pack/test/api_integration/apis/asset_manager/config_when_disabled.ts -``` - -The "enabled" tests are NOT run by CI yet, to prevent blocking Kibana development for a -test failure in this alpha, tech preview plugin. They will be moved into the right place -to make them run for CI before the plugin is enabled by default. To run them manually: - -```shell -$ node scripts/functional_tests_server --config x-pack/test/api_integration/apis/asset_manager/config.ts -$ node scripts/functional_test_runner --config=x-pack/test/api_integration/apis/asset_manager/config.ts -``` - -## Using Sample Data - -This plugin comes with a full "working set" of sample asset documents, meant -to provide enough data in the correct schema format so that all of the API -endpoints return expected values. - -To create the sample data, follow [the instructions in the REST API docs](./docs/index.md#sample-data). +For those working on this plugin directly and developing it, please see [our development docs](./docs/development.md). diff --git a/x-pack/plugins/asset_manager/common/config.ts b/x-pack/plugins/asset_manager/common/config.ts new file mode 100644 index 0000000000000..0a57e37d497bb --- /dev/null +++ b/x-pack/plugins/asset_manager/common/config.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const INDEX_DEFAULTS = { + logs: 'filebeat-*,logs-*', +}; + +export const configSchema = schema.object({ + alphaEnabled: schema.maybe(schema.boolean()), + // Designate where various types of data live. + // NOTE: this should be handled in a centralized way for observability, so + // that when a user configures these differently from the known defaults, + // that value is propagated everywhere. For now, we duplicate the value here. + sourceIndices: schema.object( + { + logs: schema.string({ defaultValue: INDEX_DEFAULTS.logs }), + }, + { defaultValue: INDEX_DEFAULTS } + ), + // Choose an explicit source for asset queries. + // NOTE: This will eventually need to be able to cleverly switch + // between these values based on the availability of data in the + // indices, and possibly for each asset kind/type value. + // For now, we set this explicitly. + lockedSource: schema.oneOf([schema.literal('assets'), schema.literal('signals')], { + defaultValue: 'signals', + }), +}); + +export type AssetManagerConfig = TypeOf; + +/** + * The following map is passed to the server plugin setup under the + * exposeToBrowser: option, and controls which of the above config + * keys are allow-listed to be available in the browser config. + * + * NOTE: anything exposed here will be visible in the UI dev tools, + * and therefore MUST NOT be anything that is sensitive information! + */ +export const exposeToBrowserConfig = { + alphaEnabled: true, +} as const; + +type ValidKeys = keyof { + [K in keyof typeof exposeToBrowserConfig as typeof exposeToBrowserConfig[K] extends true + ? K + : never]: true; +}; + +export type AssetManagerPublicConfig = Pick; diff --git a/x-pack/plugins/asset_manager/common/constants_routes.ts b/x-pack/plugins/asset_manager/common/constants_routes.ts new file mode 100644 index 0000000000000..1aef43f7383bd --- /dev/null +++ b/x-pack/plugins/asset_manager/common/constants_routes.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ASSET_MANAGER_API_BASE = '/api/asset-manager'; + +function base(path: string) { + return `${ASSET_MANAGER_API_BASE}${path}`; +} + +export const GET_ASSETS = base('/assets'); +export const GET_RELATED_ASSETS = base('/assets/related'); +export const GET_ASSETS_DIFF = base('/assets/diff'); + +export const GET_HOSTS = base('/assets/hosts'); diff --git a/x-pack/plugins/asset_manager/common/types_api.ts b/x-pack/plugins/asset_manager/common/types_api.ts index 8e9e9181a29e4..11b5ea4bda3a4 100644 --- a/x-pack/plugins/asset_manager/common/types_api.ts +++ b/x-pack/plugins/asset_manager/common/types_api.ts @@ -6,77 +6,111 @@ */ import * as rt from 'io-ts'; - -export const assetTypeRT = rt.union([ - rt.literal('k8s.pod'), - rt.literal('k8s.cluster'), - rt.literal('k8s.node'), -]); +import { + dateRt, + inRangeFromStringRt, + datemathStringRt, + createLiteralValueFromUndefinedRT, +} from '@kbn/io-ts-utils'; + +export const assetTypeRT = rt.keyof({ + 'k8s.pod': null, + 'k8s.cluster': null, + 'k8s.node': null, +}); export type AssetType = rt.TypeOf; -export const assetKindRT = rt.union([ - rt.literal('cluster'), - rt.literal('host'), - rt.literal('pod'), - rt.literal('container'), - rt.literal('service'), - rt.literal('alert'), -]); +export const assetKindRT = rt.keyof({ + cluster: null, + host: null, + pod: null, + container: null, + service: null, + alert: null, +}); export type AssetKind = rt.TypeOf; -export type AssetStatus = - | 'CREATING' - | 'ACTIVE' - | 'DELETING' - | 'FAILED' - | 'UPDATING' - | 'PENDING' - | 'UNKNOWN'; -export type CloudProviderName = 'aws' | 'gcp' | 'azure' | 'other' | 'unknown' | 'none'; - -interface WithTimestamp { - '@timestamp': string; -} -export interface ECSDocument extends WithTimestamp { - 'kubernetes.namespace'?: string; - 'kubernetes.pod.name'?: string; - 'kubernetes.pod.uid'?: string; - 'kubernetes.pod.start_time'?: Date; - 'kubernetes.node.name'?: string; - 'kubernetes.node.start_time'?: Date; - - 'orchestrator.api_version'?: string; - 'orchestrator.namespace'?: string; - 'orchestrator.organization'?: string; - 'orchestrator.type'?: string; - 'orchestrator.cluster.id'?: string; - 'orchestrator.cluster.name'?: string; - 'orchestrator.cluster.url'?: string; - 'orchestrator.cluster.version'?: string; - - 'cloud.provider'?: CloudProviderName; - 'cloud.instance.id'?: string; - 'cloud.region'?: string; - 'cloud.service.name'?: string; - - 'service.environment'?: string; -} +export const assetStatusRT = rt.keyof({ + CREATING: null, + ACTIVE: null, + DELETING: null, + FAILED: null, + UPDATING: null, + PENDING: null, + UNKNOWN: null, +}); + +export type AssetStatus = rt.TypeOf; + +// https://github.com/gcanti/io-ts/blob/master/index.md#union-of-string-literals +export const cloudProviderNameRT = rt.keyof({ + aws: null, + gcp: null, + azure: null, + other: null, + unknown: null, + none: null, +}); + +export type CloudProviderName = rt.TypeOf; + +const withTimestampRT = rt.type({ + '@timestamp': rt.string, +}); + +export type WithTimestamp = rt.TypeOf; + +export const ECSDocumentRT = rt.intersection([ + withTimestampRT, + rt.partial({ + 'kubernetes.namespace': rt.string, + 'kubernetes.pod.name': rt.string, + 'kubernetes.pod.uid': rt.string, + 'kubernetes.pod.start_time': rt.string, + 'kubernetes.node.name': rt.string, + 'kubernetes.node.start_time': rt.string, + 'orchestrator.api_version': rt.string, + 'orchestrator.namespace': rt.string, + 'orchestrator.organization': rt.string, + 'orchestrator.type': rt.string, + 'orchestrator.cluster.id': rt.string, + 'orchestrator.cluster.name': rt.string, + 'orchestrator.cluster.url': rt.string, + 'orchestrator.cluster.version': rt.string, + 'cloud.provider': cloudProviderNameRT, + 'cloud.instance.id': rt.string, + 'cloud.region': rt.string, + 'cloud.service.name': rt.string, + 'service.environment': rt.string, + }), +]); -export interface Asset extends ECSDocument { - 'asset.collection_version'?: string; - 'asset.ean': string; - 'asset.id': string; - 'asset.kind': AssetKind; - 'asset.name'?: string; - 'asset.type'?: AssetType; - 'asset.status'?: AssetStatus; - 'asset.parents'?: string | string[]; - 'asset.children'?: string | string[]; - 'asset.references'?: string | string[]; - 'asset.namespace'?: string; -} +export type ECSDocument = rt.TypeOf; + +export const assetRT = rt.intersection([ + ECSDocumentRT, + rt.type({ + 'asset.ean': rt.string, + 'asset.id': rt.string, + 'asset.kind': assetKindRT, + }), + // mixed required and optional require separate hashes combined via intersection + // https://github.com/gcanti/io-ts/blob/master/index.md#mixing-required-and-optional-props + rt.partial({ + 'asset.collection_version': rt.string, + 'asset.name': rt.string, + 'asset.type': assetTypeRT, + 'asset.status': assetStatusRT, + 'asset.parents': rt.union([rt.string, rt.array(rt.string)]), + 'asset.children': rt.union([rt.string, rt.array(rt.string)]), + 'asset.references': rt.union([rt.string, rt.array(rt.string)]), + 'asset.namespace': rt.string, + }), +]); + +export type Asset = rt.TypeOf; export type AssetWithoutTimestamp = Omit; @@ -156,3 +190,22 @@ export type RelationField = keyof Pick< Asset, 'asset.children' | 'asset.parents' | 'asset.references' >; + +export const sizeRT = rt.union([ + inRangeFromStringRt(1, 100), + createLiteralValueFromUndefinedRT(10), +]); +export const assetDateRT = rt.union([dateRt, datemathStringRt]); +export const getHostAssetsQueryOptionsRT = rt.exact( + rt.partial({ + from: assetDateRT, + to: assetDateRT, + size: sizeRT, + }) +); +export type GetHostAssetsQueryOptions = rt.TypeOf; + +export const getHostAssetsResponseRT = rt.type({ + hosts: rt.array(assetRT), +}); +export type GetHostAssetsResponse = rt.TypeOf; diff --git a/x-pack/plugins/asset_manager/common/types_client.ts b/x-pack/plugins/asset_manager/common/types_client.ts new file mode 100644 index 0000000000000..350a168da8965 --- /dev/null +++ b/x-pack/plugins/asset_manager/common/types_client.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface GetHostsOptionsPublic { + from: string; + to: string; +} + +export interface GetServicesOptionsPublic { + from: string; + to: string; + parent?: string; +} diff --git a/x-pack/plugins/asset_manager/docs/api.md b/x-pack/plugins/asset_manager/docs/api.md new file mode 100644 index 0000000000000..755abfe4be373 --- /dev/null +++ b/x-pack/plugins/asset_manager/docs/api.md @@ -0,0 +1,187 @@ +# Asset Manager API Documentation + +## Plugin configuration + +This plugin is NOT fully enabled by default, even though it's always enabled +by Kibana's definition of "enabled". However, without the following configuration, +it will bail before it sets up any routes or returns anything from its +start, setup, or stop hooks. + +To fully enable the plugin, set the following config values in your kibana.yml file: + +```yaml +xpack.assetManager: + alphaEnabled: true +``` + +## Depending on an asset client in your packages + +If you're creating a shared UI component or tool that needs to access asset data, you +can create that code in a stateless Kibana package that can itself be imported into +any Kibana plugin without any dependency restrictions. To gain access to the asset data, +this component or tool can require the appropriate asset client to be passed in. + +TODO: need to move main client types to a package so that they can be depended on by +other packages that require an injected asset client. Then we can list that package name +here and explain how to use those types in a package. + +## Client APIs + +This plugin provides asset clients for Kibana server and public usage. The differences between these +two clients are described below in their sections, while the methods for both APIs are described +in the Client Methods section. + +These clients are set up in the following way. For a given "methodA": + +``` +publicMethodA(...options: MethodAPublicOptions) + -> browser client calls corresponding REST API method with MethodAPublicOptions + -> REST API handler calls corresponding serverMethodA + -> serverMethodA requires MethodAPublicOptions & AssetClientDependencies, and it also + injects some internal dependencies from the plugin's config on your behalf +``` + +The public and server clientss are both accessible to plugin dependants, but the REST API is NOT. + +### Required dependency setup + +To use either client, you must first add "assetManager" to your `"requiredDependencies"` array +in your plugin's kibana.jsonc file. + +TECH PREVIEW NOTE: While this plugin is in "tech preview", in both the server and public clients, +the provided plugin dependencies can be undefined for this plugin if the proper configuration +has not been set (see above). For that reason, the types will force you to guard against this +undefined scenario. Once the tech preview gating is removed, this will no longer be the case. + +### Server client usage + +In your plugin's `setup` method, you can gain access to the client from the injected `plugins` map. +Make sure you import the `AssetManagerServerPluginSetup` type from the plugin's server +directory and add it to your own SetupPlugins type, as seen below. + +```ts +import { AssetManagerServerPluginSetup } from '@kbn/assetManager-plugin/server'; + +interface MyPluginSetupDeps { + assetManager: AssetManagerServerPluginSetup; +} + +class MyPlugin { + setup(core: CoreSetup, plugins: MyPluginSetupDeps) { + // assetClient is found on plugins.assetManager.assetClient + setupRoutes(router, plugins); + } +} +``` + +To use the server client in your server routes, you can use something like this: + +```ts +export function setupRoutes(router: IRouter, plugins: MyPluginDeps) { + router.get( + { + path: '/my/path', + validate: {}, + }, + async (context, req, res) => { + // handle route + // optionally, use asset client + // NOTE: see below for important info on required server client args + const hosts = await plugins.assetManager.assetClient.getHosts(); + } + ); +} +``` + +#### Required parameters for server client methods + +All methods called via the server client require some core Kibana clients to be passed in, +so that they are pulled from the request context and properly scoped. If the asset manager +plugin provided these clients internally, they would not be scoped to the user that made +the API request, so they are required arguments for every server client method. + +_Note: These required arguments are referred to as `AssetClientDependencies`, which can be +seen in the [the server types file](../server/types.ts)._ + +For example: + +```ts +router.get( + { + path: '/my/path', + validate: {}, + }, + async (context, req, res) => { + // to use server asset client, you must get the following clients + // from the request context and pass them to the client method + // alongside whatever "public" arguments that method defines + const coreContext = await context.core; + const hostsOptions: PublicGetHostsOptions = {}; // these will be different for each method + + const hosts = await plugins.assetManager.assetClient.getHosts({ + ...hostsOptions, + elasticsearchClient: coreContext.elasticsearch.client.asCurrentUser, + savedObjectsClient: coreContext.savedObjects.client, + }); + } +); +``` + +### Public client usage + +You should grab the public client in the same way as the server one, via the plugin dependencies +in your `setup` lifecycle. + +```ts +import { AssetManagerPublicPluginStart } from '@kbn/assetManager-plugin/public'; + +interface MyPluginStartDeps { + assetManager: AssetManagerPublicPluginStart; +} + +class MyPlugin { + setup(core: CoreSetup) { + core.application.register({ + id: 'my-other-plugin', + title: '', + appRoute: '/app/my-other-plugin', + mount: async (params: AppMountParameters) => { + // mount callback should not use setup dependencies, get start dependencies instead + // so the pluginStart map passed to your renderApp method will be the start deps, + // not the setup deps -- the same asset client is provided to both setup and start in public + const [coreStart, , pluginStart] = await core.getStartServices(); + // assetClient is found on pluginStart.assetManager.assetClient + return renderApp(coreStart, pluginStart, params); + }, + }); + } +} +``` + +All methods in the public client only require their public options (seen below), and don't require +the "AssetClientDependencies" that are required for the server client versions of the same methods. +This is because the public client will use the asset manager's internal REST API under the hood, where +it will be able to pull the properly-scoped client dependencies off of that request context for you. + +### Client methods + +#### getHosts + +Get a group of host assets found within a specified time range. + +| Parameter | Type | Required? | Description | +| :-------- | :-------------- | :-------- | :--------------------------------------------------------------------- | +| from | datetime string | yes | ISO date string representing the START of the time range being queried | +| to | datetime string | yes | ISO date string representing the END of the time range being queried | + +**Response** + +```json +{ + "hosts": [ + ...found host assets + ] +} +``` + +TODO: Link to a centralized asset document example that each response can reference? diff --git a/x-pack/plugins/asset_manager/docs/development.md b/x-pack/plugins/asset_manager/docs/development.md new file mode 100644 index 0000000000000..a98e8e46a8ce4 --- /dev/null +++ b/x-pack/plugins/asset_manager/docs/development.md @@ -0,0 +1,34 @@ +# Asset Manager Plugin Development + +These docs contain information you might need if you are developing this plugin in Kibana. If you are interested in the APIs this plugin exposes, please see [./api.md](our API docs) instead. + +## Running Tests + +There are integration tests for the endpoints implemented thus far as well as for +the sample data tests. There is also a small set of tests meant to ensure that the +plugin is not doing anything without the proper config value in place to enable +the plugin fully. For more on enabling the plugin, see [the docs page](./docs/index.md). + +The "not enabled" tests are run by default in CI. To run them manually, do the following: + +```shell +$ node scripts/functional_tests_server --config x-pack/test/api_integration/apis/asset_manager/config_when_disabled.ts +$ node scripts/functional_test_runner --config=x-pack/test/api_integration/apis/asset_manager/config_when_disabled.ts +``` + +The "enabled" tests are NOT run by CI yet, to prevent blocking Kibana development for a +test failure in this alpha, tech preview plugin. They will be moved into the right place +to make them run for CI before the plugin is enabled by default. To run them manually: + +```shell +$ node scripts/functional_tests_server --config x-pack/test/api_integration/apis/asset_manager/config.ts +$ node scripts/functional_test_runner --config=x-pack/test/api_integration/apis/asset_manager/config.ts +``` + +## Using Sample Data + +This plugin comes with a full "working set" of sample asset documents, meant +to provide enough data in the correct schema format so that all of the API +endpoints return expected values. + +To create the sample data, follow [the instructions in the REST API docs](./docs/index.md#sample-data). diff --git a/x-pack/plugins/asset_manager/docs/index.md b/x-pack/plugins/asset_manager/docs/rest_deprecated.md similarity index 93% rename from x-pack/plugins/asset_manager/docs/index.md rename to x-pack/plugins/asset_manager/docs/rest_deprecated.md index 790beb87b4f7e..43a5f74a0d058 100644 --- a/x-pack/plugins/asset_manager/docs/index.md +++ b/x-pack/plugins/asset_manager/docs/rest_deprecated.md @@ -1,24 +1,6 @@ -# Asset Manager Documentation +## Deprecated REST API docs -_Note:_ To read about development guidance around testing, sample data, etc., see the -[plugin's main README file](../README.md) - -## Alpha Configuration - -This plugin is NOT fully enabled by default, even though it's always enabled -by Kibana's definition of "enabled". However, without the following configuration, -it will bail before it sets up any routes or returns anything from its -start, setup, or stop hooks. - -To fully enable the plugin, set the following config value in your kibana.yml file: - -```yaml -xpack.assetManager.alphaEnabled: true -``` - -## APIs - -This plugin provides the following APIs. +These docs are not being currently maintained because they pertain to an internal REST API. Please see [our docs for our API clients](./api.md) instead. ### Shared Types @@ -58,16 +40,16 @@ Returns a list of assets present within a given time range. Can be limited by as ##### Request -| Option | Type | Required? | Default | Description | -| :------ | :------------ | :-------- | :------ | :--------------------------------------------------------------------------------- | -| from | RangeDate | No | "now-24h" | Starting point for date range to search for assets within | -| to | RangeDate | No | "now" | End point for date range to search for assets | -| type | AssetType[] | No | all | Specify one or more types to restrict the query | -| ean | AssetEan[] | No | all | Specify one or more EANs (specific assets) to restrict the query | -| size | number | No | all | Limit the amount of assets returned | - +| Option | Type | Required? | Default | Description | +| :----- | :---------- | :-------- | :-------- | :--------------------------------------------------------------- | +| from | RangeDate | No | "now-24h" | Starting point for date range to search for assets within | +| to | RangeDate | No | "now" | End point for date range to search for assets | +| type | AssetType[] | No | all | Specify one or more types to restrict the query | +| ean | AssetEan[] | No | all | Specify one or more EANs (specific assets) to restrict the query | +| size | number | No | all | Limit the amount of assets returned | _Notes:_ + - User cannot specify both type and ean at the same time. - For array types such as `type` and `ean`, user should specify the query parameter multiple times, e.g. `type=k8s.pod&type=k8s.node` @@ -410,15 +392,15 @@ GET kbn:/api/asset-manager/assets?from=2023-03-25T17:44:44.000Z&to=2023-03-25T18 Returns assets found in the two time ranges, split by what occurs in only either or in both. -#### Request +#### Request -| Option | Type | Required? | Default | Description | -| :--- | :--- | :--- | :--- | :--- | -| aFrom | RangeDate | Yes | N/A | Starting point for baseline date range to search for assets within | -| aTo | RangeDate | Yes | N/A | End point for baseline date range to search for assets within | -| bFrom | RangeDate | Yes | N/A | Starting point for comparison date range | -| bTo | RangeDate | Yes | N/A | End point for comparison date range | -| type | AssetType[] | No | all | Restrict results to one or more asset.type value | +| Option | Type | Required? | Default | Description | +| :----- | :---------- | :-------- | :------ | :----------------------------------------------------------------- | +| aFrom | RangeDate | Yes | N/A | Starting point for baseline date range to search for assets within | +| aTo | RangeDate | Yes | N/A | End point for baseline date range to search for assets within | +| bFrom | RangeDate | Yes | N/A | Starting point for comparison date range | +| bTo | RangeDate | Yes | N/A | End point for comparison date range | +| type | AssetType[] | No | all | Restrict results to one or more asset.type value | #### Responses @@ -1044,14 +1026,14 @@ Returns assets related to the provided ean. The relation can be one of ancestors #### Request -| Option | Type | Required? | Default | Description | -| :--- | :--- | :--- | :--- | :--- | -| relation | string | Yes | N/A | The type of related assets we're looking for. One of (ancestors|descendants|references) | -| from | RangeDate | Yes | N/A | Starting point for date range to search for assets within | -| to | RangeDate | No | "now" | End point for date range to search for assets | -| ean | AssetEan | Yes | N/A | Single Elastic Asset Name representing the asset for which the related assets are being requested | -| type | AssetType[] | No | all | Restrict results to one or more asset.type value | -| maxDistance | number (1-5) | No | 1 | Maximum number of "hops" to search away from specified asset | +| Option | Type | Required? | Default | Description | +| :---------- | :----------- | :-------- | :------ | :------------------------------------------------------------------------------------------------ | ----------- | ----------- | +| relation | string | Yes | N/A | The type of related assets we're looking for. One of (ancestors | descendants | references) | +| from | RangeDate | Yes | N/A | Starting point for date range to search for assets within | +| to | RangeDate | No | "now" | End point for date range to search for assets | +| ean | AssetEan | Yes | N/A | Single Elastic Asset Name representing the asset for which the related assets are being requested | +| type | AssetType[] | No | all | Restrict results to one or more asset.type value | +| maxDistance | number (1-5) | No | 1 | Maximum number of "hops" to search away from specified asset | #### Responses diff --git a/x-pack/plugins/asset_manager/kibana.jsonc b/x-pack/plugins/asset_manager/kibana.jsonc index 49b1b59838d9c..b3fcd1b3a4fa1 100644 --- a/x-pack/plugins/asset_manager/kibana.jsonc +++ b/x-pack/plugins/asset_manager/kibana.jsonc @@ -15,7 +15,7 @@ "apmDataAccess", "metricsDataAccess" ], - "browser": false, + "browser": true, "server": true, "requiredBundles": [ ] diff --git a/x-pack/plugins/asset_manager/public/index.ts b/x-pack/plugins/asset_manager/public/index.ts new file mode 100644 index 0000000000000..7837c00909430 --- /dev/null +++ b/x-pack/plugins/asset_manager/public/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializer, PluginInitializerContext } from '@kbn/core/public'; +import { Plugin } from './plugin'; +import { AssetManagerPublicPluginSetup, AssetManagerPublicPluginStart } from './types'; + +export const plugin: PluginInitializer< + AssetManagerPublicPluginSetup | undefined, + AssetManagerPublicPluginStart | undefined +> = (context: PluginInitializerContext) => { + return new Plugin(context); +}; + +export type { AssetManagerPublicPluginSetup, AssetManagerPublicPluginStart }; +export type AssetManagerAppId = 'assetManager'; diff --git a/x-pack/plugins/asset_manager/public/lib/public_assets_client.test.ts b/x-pack/plugins/asset_manager/public/lib/public_assets_client.test.ts new file mode 100644 index 0000000000000..93cc541a34af4 --- /dev/null +++ b/x-pack/plugins/asset_manager/public/lib/public_assets_client.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetupMock } from '@kbn/core-http-browser-mocks'; +import { coreMock } from '@kbn/core/public/mocks'; +import { PublicAssetsClient } from './public_assets_client'; +import * as routePaths from '../../common/constants_routes'; + +describe('Public assets client', () => { + let http: HttpSetupMock = coreMock.createSetup().http; + + beforeEach(() => { + http = coreMock.createSetup().http; + }); + + describe('class instantiation', () => { + it('should successfully instantiate', () => { + new PublicAssetsClient(http); + }); + }); + + describe('getHosts', () => { + it('should call the REST API', async () => { + const client = new PublicAssetsClient(http); + await client.getHosts({ from: 'x', to: 'y' }); + expect(http.get).toBeCalledTimes(1); + }); + + it('should include specified "from" and "to" parameters in http.get query', async () => { + const client = new PublicAssetsClient(http); + await client.getHosts({ from: 'x', to: 'y' }); + expect(http.get).toBeCalledWith(routePaths.GET_HOSTS, { + query: { from: 'x', to: 'y' }, + }); + }); + + it('should return the direct results of http.get', async () => { + const client = new PublicAssetsClient(http); + http.get.mockResolvedValueOnce('my result'); + const result = await client.getHosts({ from: 'x', to: 'y' }); + expect(result).toBe('my result'); + }); + }); +}); diff --git a/x-pack/plugins/asset_manager/public/lib/public_assets_client.ts b/x-pack/plugins/asset_manager/public/lib/public_assets_client.ts new file mode 100644 index 0000000000000..dd18386868f94 --- /dev/null +++ b/x-pack/plugins/asset_manager/public/lib/public_assets_client.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpStart } from '@kbn/core/public'; +import { GetHostsOptionsPublic } from '../../common/types_client'; +import { GetHostAssetsResponse } from '../../common/types_api'; +import { GET_HOSTS } from '../../common/constants_routes'; +import { IPublicAssetsClient } from '../types'; + +export class PublicAssetsClient implements IPublicAssetsClient { + constructor(private readonly http: HttpStart) {} + + async getHosts(options: GetHostsOptionsPublic) { + const results = await this.http.get(GET_HOSTS, { + query: { + ...options, + }, + }); + + return results; + } +} diff --git a/x-pack/plugins/asset_manager/public/plugin.ts b/x-pack/plugins/asset_manager/public/plugin.ts new file mode 100644 index 0000000000000..4b2d91f3a60f1 --- /dev/null +++ b/x-pack/plugins/asset_manager/public/plugin.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup, CoreStart, PluginInitializerContext } from '@kbn/core/public'; +import { Logger } from '@kbn/logging'; +import { AssetManagerPluginClass } from './types'; +import { PublicAssetsClient } from './lib/public_assets_client'; +import type { AssetManagerPublicConfig } from '../common/config'; + +export class Plugin implements AssetManagerPluginClass { + public config: AssetManagerPublicConfig; + public logger: Logger; + + constructor(context: PluginInitializerContext<{}>) { + this.config = context.config.get(); + this.logger = context.logger.get(); + } + + setup(core: CoreSetup) { + // Check for config value and bail out if not "alpha-enabled" + if (!this.config.alphaEnabled) { + this.logger.debug('Public is NOT enabled'); + return; + } + + this.logger.debug('Public is enabled'); + + const publicAssetsClient = new PublicAssetsClient(core.http); + return { + publicAssetsClient, + }; + } + + start(core: CoreStart) { + // Check for config value and bail out if not "alpha-enabled" + if (!this.config.alphaEnabled) { + return; + } + + const publicAssetsClient = new PublicAssetsClient(core.http); + return { + publicAssetsClient, + }; + } + + stop() {} +} diff --git a/x-pack/plugins/asset_manager/public/types.ts b/x-pack/plugins/asset_manager/public/types.ts new file mode 100644 index 0000000000000..67f0053cfdd56 --- /dev/null +++ b/x-pack/plugins/asset_manager/public/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { Plugin as PluginClass } from '@kbn/core/public'; +import { GetHostsOptionsPublic } from '../common/types_client'; +import { GetHostAssetsResponse } from '../common/types_api'; +export interface AssetManagerPublicPluginSetup { + publicAssetsClient: IPublicAssetsClient; +} + +export interface AssetManagerPublicPluginStart { + publicAssetsClient: IPublicAssetsClient; +} + +export type AssetManagerPluginClass = PluginClass< + AssetManagerPublicPluginSetup | undefined, + AssetManagerPublicPluginStart | undefined +>; + +export interface IPublicAssetsClient { + getHosts: (options: GetHostsOptionsPublic) => Promise; +} diff --git a/x-pack/plugins/asset_manager/server/constants.ts b/x-pack/plugins/asset_manager/server/constants.ts index 0aa1cb467df48..4630365e47875 100644 --- a/x-pack/plugins/asset_manager/server/constants.ts +++ b/x-pack/plugins/asset_manager/server/constants.ts @@ -6,4 +6,3 @@ */ export const ASSETS_INDEX_PREFIX = 'assets'; -export const ASSET_MANAGER_API_BASE = '/api/asset-manager'; diff --git a/x-pack/plugins/asset_manager/server/index.ts b/x-pack/plugins/asset_manager/server/index.ts index d6eafa380b857..5dbecbee5f9da 100644 --- a/x-pack/plugins/asset_manager/server/index.ts +++ b/x-pack/plugins/asset_manager/server/index.ts @@ -6,11 +6,21 @@ */ import { PluginInitializerContext } from '@kbn/core-plugins-server'; -import { AssetManagerServerPlugin, config } from './plugin'; +import { AssetManagerConfig } from '../common/config'; +import { + AssetManagerServerPlugin, + AssetManagerServerPluginSetup, + AssetManagerServerPluginStart, + config, +} from './plugin'; import type { WriteSamplesPostBody } from './routes/sample_assets'; -import { AssetManagerConfig } from './types'; -export type { AssetManagerConfig, WriteSamplesPostBody }; +export type { + AssetManagerConfig, + WriteSamplesPostBody, + AssetManagerServerPluginSetup, + AssetManagerServerPluginStart, +}; export { config }; export const plugin = (context: PluginInitializerContext) => diff --git a/x-pack/plugins/asset_manager/server/lib/accessors/hosts/get_hosts_by_assets.ts b/x-pack/plugins/asset_manager/server/lib/accessors/hosts/get_hosts_by_assets.ts index 13e2d00a82083..f975df1cd82f4 100644 --- a/x-pack/plugins/asset_manager/server/lib/accessors/hosts/get_hosts_by_assets.ts +++ b/x-pack/plugins/asset_manager/server/lib/accessors/hosts/get_hosts_by_assets.ts @@ -13,7 +13,7 @@ export async function getHostsByAssets( options: GetHostsOptionsInjected ): Promise<{ hosts: Asset[] }> { const hosts = await getAssets({ - esClient: options.esClient, + elasticsearchClient: options.elasticsearchClient, filters: { kind: 'host', from: options.from, diff --git a/x-pack/plugins/asset_manager/server/lib/accessors/hosts/get_hosts_by_signals.ts b/x-pack/plugins/asset_manager/server/lib/accessors/hosts/get_hosts_by_signals.ts index 4fad9e301a89d..93e601ae00f9c 100644 --- a/x-pack/plugins/asset_manager/server/lib/accessors/hosts/get_hosts_by_signals.ts +++ b/x-pack/plugins/asset_manager/server/lib/accessors/hosts/get_hosts_by_signals.ts @@ -13,11 +13,11 @@ export async function getHostsBySignals( options: GetHostsOptionsInjected ): Promise<{ hosts: Asset[] }> { const metricsIndices = await options.metricsClient.getMetricIndices({ - savedObjectsClient: options.soClient, + savedObjectsClient: options.savedObjectsClient, }); const { assets } = await collectHosts({ - client: options.esClient, + client: options.elasticsearchClient, from: options.from, to: options.to, sourceIndices: { diff --git a/x-pack/plugins/asset_manager/server/lib/accessors/hosts/index.ts b/x-pack/plugins/asset_manager/server/lib/accessors/hosts/index.ts index 9becb15ebdc0a..1b60268d85389 100644 --- a/x-pack/plugins/asset_manager/server/lib/accessors/hosts/index.ts +++ b/x-pack/plugins/asset_manager/server/lib/accessors/hosts/index.ts @@ -5,12 +5,11 @@ * 2.0. */ -import { AccessorOptions, OptionsWithInjectedValues } from '..'; +import type { AssetClientDependencies } from '../../../types'; +import type { GetHostsOptionsPublic } from '../../../../common/types_client'; +import type { OptionsWithInjectedValues } from '..'; -export interface GetHostsOptions extends AccessorOptions { - from: string; - to: string; -} +export type GetHostsOptions = GetHostsOptionsPublic & AssetClientDependencies; export type GetHostsOptionsInjected = OptionsWithInjectedValues; export interface HostIdentifier { diff --git a/x-pack/plugins/asset_manager/server/lib/accessors/index.ts b/x-pack/plugins/asset_manager/server/lib/accessors/index.ts index f5cf4d38fadc8..6fd9254a2182e 100644 --- a/x-pack/plugins/asset_manager/server/lib/accessors/index.ts +++ b/x-pack/plugins/asset_manager/server/lib/accessors/index.ts @@ -5,11 +5,10 @@ * 2.0. */ -import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { APMDataAccessConfig } from '@kbn/apm-data-access-plugin/server'; import { MetricsDataClient } from '@kbn/metrics-data-access-plugin/server'; import { SavedObjectsClientContract } from '@kbn/core/server'; -import { AssetManagerConfig } from '../../types'; +import { AssetManagerConfig } from '../../../common/config'; export interface InjectedValues { sourceIndices: AssetManagerConfig['sourceIndices']; @@ -18,8 +17,3 @@ export interface InjectedValues { } export type OptionsWithInjectedValues = T & InjectedValues; - -export interface AccessorOptions { - esClient: ElasticsearchClient; - soClient: SavedObjectsClientContract; -} diff --git a/x-pack/plugins/asset_manager/server/lib/accessors/services/get_services_by_assets.ts b/x-pack/plugins/asset_manager/server/lib/accessors/services/get_services_by_assets.ts index 8bdd6283d6559..8e69bcbff4625 100644 --- a/x-pack/plugins/asset_manager/server/lib/accessors/services/get_services_by_assets.ts +++ b/x-pack/plugins/asset_manager/server/lib/accessors/services/get_services_by_assets.ts @@ -18,7 +18,7 @@ export async function getServicesByAssets( } const services = await getAssets({ - esClient: options.esClient, + elasticsearchClient: options.elasticsearchClient, filters: { kind: 'service', from: options.from, @@ -32,7 +32,7 @@ export async function getServicesByAssets( async function getServicesByParent( options: GetServicesOptionsInjected ): Promise<{ services: Asset[] }> { - const { descendants } = await getAllRelatedAssets(options.esClient, { + const { descendants } = await getAllRelatedAssets(options.elasticsearchClient, { from: options.from, to: options.to, maxDistance: 5, diff --git a/x-pack/plugins/asset_manager/server/lib/accessors/services/get_services_by_signals.ts b/x-pack/plugins/asset_manager/server/lib/accessors/services/get_services_by_signals.ts index ab8de39adb301..720d6b3e30531 100644 --- a/x-pack/plugins/asset_manager/server/lib/accessors/services/get_services_by_signals.ts +++ b/x-pack/plugins/asset_manager/server/lib/accessors/services/get_services_by_signals.ts @@ -26,9 +26,9 @@ export async function getServicesBySignals( }); } - const apmIndices = await options.getApmIndices(options.soClient); + const apmIndices = await options.getApmIndices(options.savedObjectsClient); const { assets } = await collectServices({ - client: options.esClient, + client: options.elasticsearchClient, from: options.from, to: options.to, sourceIndices: { diff --git a/x-pack/plugins/asset_manager/server/lib/accessors/services/index.ts b/x-pack/plugins/asset_manager/server/lib/accessors/services/index.ts index 3fed1047eacba..e8b52e4924c4d 100644 --- a/x-pack/plugins/asset_manager/server/lib/accessors/services/index.ts +++ b/x-pack/plugins/asset_manager/server/lib/accessors/services/index.ts @@ -5,13 +5,11 @@ * 2.0. */ -import { AccessorOptions, OptionsWithInjectedValues } from '..'; +import { AssetClientDependencies } from '../../../types'; +import { GetServicesOptionsPublic } from '../../../../common/types_client'; +import { OptionsWithInjectedValues } from '..'; -export interface GetServicesOptions extends AccessorOptions { - from: string; - to: string; - parent?: string; -} +export type GetServicesOptions = GetServicesOptionsPublic & AssetClientDependencies; export type GetServicesOptionsInjected = OptionsWithInjectedValues; export interface ServiceIdentifier { diff --git a/x-pack/plugins/asset_manager/server/lib/asset_accessor.ts b/x-pack/plugins/asset_manager/server/lib/asset_client.ts similarity index 81% rename from x-pack/plugins/asset_manager/server/lib/asset_accessor.ts rename to x-pack/plugins/asset_manager/server/lib/asset_client.ts index 73c2064e48311..8bf23313c663e 100644 --- a/x-pack/plugins/asset_manager/server/lib/asset_accessor.ts +++ b/x-pack/plugins/asset_manager/server/lib/asset_client.ts @@ -8,8 +8,8 @@ import { APMDataAccessConfig } from '@kbn/apm-data-access-plugin/server'; import { MetricsDataClient } from '@kbn/metrics-data-access-plugin/server'; import { SavedObjectsClientContract } from '@kbn/core/server'; +import { AssetManagerConfig } from '../../common/config'; import { Asset } from '../../common/types_api'; -import { AssetManagerConfig } from '../types'; import { OptionsWithInjectedValues } from './accessors'; import { GetHostsOptions } from './accessors/hosts'; import { GetServicesOptions } from './accessors/services'; @@ -18,28 +18,28 @@ import { getHostsBySignals } from './accessors/hosts/get_hosts_by_signals'; import { getServicesByAssets } from './accessors/services/get_services_by_assets'; import { getServicesBySignals } from './accessors/services/get_services_by_signals'; -interface AssetAccessorClassOptions { +interface AssetClientClassOptions { sourceIndices: AssetManagerConfig['sourceIndices']; source: AssetManagerConfig['lockedSource']; getApmIndices: (soClient: SavedObjectsClientContract) => Promise; metricsClient: MetricsDataClient; } -export class AssetAccessor { - constructor(private options: AssetAccessorClassOptions) {} +export class AssetClient { + constructor(private baseOptions: AssetClientClassOptions) {} injectOptions(options: T): OptionsWithInjectedValues { return { ...options, - sourceIndices: this.options.sourceIndices, - getApmIndices: this.options.getApmIndices, - metricsClient: this.options.metricsClient, + sourceIndices: this.baseOptions.sourceIndices, + getApmIndices: this.baseOptions.getApmIndices, + metricsClient: this.baseOptions.metricsClient, }; } async getHosts(options: GetHostsOptions): Promise<{ hosts: Asset[] }> { const withInjected = this.injectOptions(options); - if (this.options.source === 'assets') { + if (this.baseOptions.source === 'assets') { return await getHostsByAssets(withInjected); } else { return await getHostsBySignals(withInjected); @@ -48,7 +48,7 @@ export class AssetAccessor { async getServices(options: GetServicesOptions): Promise<{ services: Asset[] }> { const withInjected = this.injectOptions(options); - if (this.options.source === 'assets') { + if (this.baseOptions.source === 'assets') { return await getServicesByAssets(withInjected); } else { return await getServicesBySignals(withInjected); diff --git a/x-pack/plugins/asset_manager/server/lib/get_all_related_assets.ts b/x-pack/plugins/asset_manager/server/lib/get_all_related_assets.ts index ad8aff78cbb18..dddbb792b0979 100644 --- a/x-pack/plugins/asset_manager/server/lib/get_all_related_assets.ts +++ b/x-pack/plugins/asset_manager/server/lib/get_all_related_assets.ts @@ -26,13 +26,13 @@ interface GetAllRelatedAssetsOptions { } export async function getAllRelatedAssets( - esClient: ElasticsearchClient, + elasticsearchClient: ElasticsearchClient, options: GetAllRelatedAssetsOptions ) { // How to put size into this? const { ean, from, to, relation, maxDistance, kind = [] } = options; - const primary = await findPrimary(esClient, { ean, from, to }); + const primary = await findPrimary(elasticsearchClient, { ean, from, to }); let assetsToFetch = [primary]; let currentDistance = 1; @@ -52,7 +52,7 @@ export async function getAllRelatedAssets( const results = flatten( await Promise.all( - assetsToFetch.map((asset) => findRelatedAssets(esClient, asset, queryOptions)) + assetsToFetch.map((asset) => findRelatedAssets(elasticsearchClient, asset, queryOptions)) ) ); @@ -75,11 +75,11 @@ export async function getAllRelatedAssets( } async function findPrimary( - esClient: ElasticsearchClient, + elasticsearchClient: ElasticsearchClient, { ean, from, to }: Pick ): Promise { const primaryResults = await getAssets({ - esClient, + elasticsearchClient, size: 1, filters: { ean, from, to }, }); @@ -101,7 +101,7 @@ type FindRelatedAssetsOptions = Pick< > & { visitedEans: string[] }; async function findRelatedAssets( - esClient: ElasticsearchClient, + elasticsearchClient: ElasticsearchClient, primary: Asset, { relation, from, to, kind, visitedEans }: FindRelatedAssetsOptions ): Promise { @@ -116,7 +116,7 @@ async function findRelatedAssets( const remainingEansToFind = without(directlyRelatedEans, ...visitedEans); if (remainingEansToFind.length > 0) { directlyRelatedAssets = await getAssets({ - esClient, + elasticsearchClient, filters: { ean: remainingEansToFind, from, to, kind }, }); } @@ -124,7 +124,7 @@ async function findRelatedAssets( debug('Directly related assets found:', JSON.stringify(directlyRelatedAssets)); const indirectlyRelatedAssets = await getIndirectlyRelatedAssets({ - esClient, + elasticsearchClient, ean: primary['asset.ean'], excludeEans: visitedEans.concat(directlyRelatedEans), relation, diff --git a/x-pack/plugins/asset_manager/server/lib/get_assets.ts b/x-pack/plugins/asset_manager/server/lib/get_assets.ts index 12f87e4b398fc..e3630f92f26e9 100644 --- a/x-pack/plugins/asset_manager/server/lib/get_assets.ts +++ b/x-pack/plugins/asset_manager/server/lib/get_assets.ts @@ -19,7 +19,7 @@ interface GetAssetsOptions extends ElasticsearchAccessorOptions { } export async function getAssets({ - esClient, + elasticsearchClient, size = 100, filters = {}, }: GetAssetsOptions): Promise { @@ -125,6 +125,6 @@ export async function getAssets({ debug('Performing Get Assets Query', '\n\n', JSON.stringify(dsl, null, 2)); - const response = await esClient.search(dsl); + const response = await elasticsearchClient.search(dsl); return response.hits.hits.map((hit) => hit._source).filter((asset): asset is Asset => !!asset); } diff --git a/x-pack/plugins/asset_manager/server/lib/get_indirectly_related_assets.ts b/x-pack/plugins/asset_manager/server/lib/get_indirectly_related_assets.ts index fa9f3279ec497..b91242f4aba1b 100644 --- a/x-pack/plugins/asset_manager/server/lib/get_indirectly_related_assets.ts +++ b/x-pack/plugins/asset_manager/server/lib/get_indirectly_related_assets.ts @@ -23,7 +23,7 @@ interface GetRelatedAssetsOptions extends ElasticsearchAccessorOptions { } export async function getIndirectlyRelatedAssets({ - esClient, + elasticsearchClient, size = 100, from = 'now-24h', to = 'now', @@ -91,7 +91,7 @@ export async function getIndirectlyRelatedAssets({ debug('Performing Indirectly Related Asset Query', '\n\n', JSON.stringify(dsl, null, 2)); - const response = await esClient.search(dsl); + const response = await elasticsearchClient.search(dsl); return response.hits.hits.map((hit) => hit._source).filter((asset): asset is Asset => !!asset); } diff --git a/x-pack/plugins/asset_manager/server/lib/write_assets.ts b/x-pack/plugins/asset_manager/server/lib/write_assets.ts index 55c5397645725..72b79bc366b6d 100644 --- a/x-pack/plugins/asset_manager/server/lib/write_assets.ts +++ b/x-pack/plugins/asset_manager/server/lib/write_assets.ts @@ -18,7 +18,7 @@ interface WriteAssetsOptions extends ElasticsearchAccessorOptions { } export async function writeAssets({ - esClient, + elasticsearchClient, assetDocs, namespace = 'default', refresh = false, @@ -33,5 +33,5 @@ export async function writeAssets({ debug('Performing Write Asset Query', '\n\n', JSON.stringify(dsl, null, 2)); - return await esClient.bulk<{}>(dsl); + return await elasticsearchClient.bulk<{}>(dsl); } diff --git a/x-pack/plugins/asset_manager/server/plugin.ts b/x-pack/plugins/asset_manager/server/plugin.ts index 6693e6037a836..24563b5e0fbc1 100644 --- a/x-pack/plugins/asset_manager/server/plugin.ts +++ b/x-pack/plugins/asset_manager/server/plugin.ts @@ -18,15 +18,16 @@ import { import { upsertTemplate } from './lib/manage_index_templates'; import { setupRoutes } from './routes'; import { assetsIndexTemplateConfig } from './templates/assets_template'; -import { AssetManagerConfig, configSchema } from './types'; -import { AssetAccessor } from './lib/asset_accessor'; +import { AssetClient } from './lib/asset_client'; import { AssetManagerPluginSetupDependencies, AssetManagerPluginStartDependencies } from './types'; +import { AssetManagerConfig, configSchema, exposeToBrowserConfig } from '../common/config'; export type AssetManagerServerPluginSetup = ReturnType; export type AssetManagerServerPluginStart = ReturnType; export const config: PluginConfigDescriptor = { schema: configSchema, + exposeToBrowser: exposeToBrowserConfig, }; export class AssetManagerServerPlugin @@ -49,13 +50,13 @@ export class AssetManagerServerPlugin public setup(core: CoreSetup, plugins: AssetManagerPluginSetupDependencies) { // Check for config value and bail out if not "alpha-enabled" if (!this.config.alphaEnabled) { - this.logger.info('Asset manager plugin [tech preview] is NOT enabled'); + this.logger.info('Server is NOT enabled'); return; } - this.logger.info('Asset manager plugin [tech preview] is enabled'); + this.logger.info('Server is enabled'); - const assetAccessor = new AssetAccessor({ + const assetClient = new AssetClient({ source: this.config.lockedSource, sourceIndices: this.config.sourceIndices, getApmIndices: plugins.apmDataAccess.getApmIndices, @@ -63,10 +64,10 @@ export class AssetManagerServerPlugin }); const router = core.http.createRouter(); - setupRoutes({ router, assetAccessor }); + setupRoutes({ router, assetClient }); return { - assetAccessor, + assetClient, }; } diff --git a/x-pack/plugins/asset_manager/server/routes/assets/hosts.ts b/x-pack/plugins/asset_manager/server/routes/assets/hosts.ts index e17ad95f81a24..f7780f2ef4a6c 100644 --- a/x-pack/plugins/asset_manager/server/routes/assets/hosts.ts +++ b/x-pack/plugins/asset_manager/server/routes/assets/hosts.ts @@ -5,56 +5,37 @@ * 2.0. */ -import * as rt from 'io-ts'; import datemath from '@kbn/datemath'; -import { - dateRt, - inRangeFromStringRt, - datemathStringRt, - createRouteValidationFunction, - createLiteralValueFromUndefinedRT, -} from '@kbn/io-ts-utils'; +import { createRouteValidationFunction } from '@kbn/io-ts-utils'; import { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; +import { GetHostAssetsQueryOptions, getHostAssetsQueryOptionsRT } from '../../../common/types_api'; import { debug } from '../../../common/debug_log'; import { SetupRouteOptions } from '../types'; -import { ASSET_MANAGER_API_BASE } from '../../constants'; -import { getEsClientFromContext } from '../utils'; - -const sizeRT = rt.union([inRangeFromStringRt(1, 100), createLiteralValueFromUndefinedRT(10)]); -const assetDateRT = rt.union([dateRt, datemathStringRt]); -const getHostAssetsQueryOptionsRT = rt.exact( - rt.partial({ - from: assetDateRT, - to: assetDateRT, - size: sizeRT, - }) -); - -export type GetHostAssetsQueryOptions = rt.TypeOf; +import * as routePaths from '../../../common/constants_routes'; +import { getClientsFromContext } from '../utils'; export function hostsRoutes({ router, - assetAccessor, + assetClient, }: SetupRouteOptions) { router.get( { - path: `${ASSET_MANAGER_API_BASE}/assets/hosts`, + path: routePaths.GET_HOSTS, validate: { query: createRouteValidationFunction(getHostAssetsQueryOptionsRT), }, }, async (context, req, res) => { const { from = 'now-24h', to = 'now' } = req.query || {}; - const esClient = await getEsClientFromContext(context); - const coreContext = await context.core; - const soClient = coreContext.savedObjects.client; + + const { elasticsearchClient, savedObjectsClient } = await getClientsFromContext(context); try { - const response = await assetAccessor.getHosts({ + const response = await assetClient.getHosts({ from: datemath.parse(from)!.toISOString(), to: datemath.parse(to)!.toISOString(), - esClient, - soClient, + elasticsearchClient, + savedObjectsClient, }); return res.ok({ body: response }); diff --git a/x-pack/plugins/asset_manager/server/routes/assets/index.ts b/x-pack/plugins/asset_manager/server/routes/assets/index.ts index b8b6d7ab0fa3a..8d9eaff170d30 100644 --- a/x-pack/plugins/asset_manager/server/routes/assets/index.ts +++ b/x-pack/plugins/asset_manager/server/routes/assets/index.ts @@ -17,11 +17,11 @@ import { } from '@kbn/io-ts-utils'; import { debug } from '../../../common/debug_log'; import { assetTypeRT, assetKindRT, relationRT } from '../../../common/types_api'; -import { ASSET_MANAGER_API_BASE } from '../../constants'; +import { GET_ASSETS, GET_RELATED_ASSETS, GET_ASSETS_DIFF } from '../../../common/constants_routes'; import { getAssets } from '../../lib/get_assets'; import { getAllRelatedAssets } from '../../lib/get_all_related_assets'; import { SetupRouteOptions } from '../types'; -import { getEsClientFromContext } from '../utils'; +import { getClientsFromContext } from '../utils'; import { AssetNotFoundError } from '../../lib/errors'; import { isValidRange } from '../../lib/utils'; @@ -82,7 +82,7 @@ export function assetsRoutes({ router }: SetupR // GET /assets router.get( { - path: `${ASSET_MANAGER_API_BASE}/assets`, + path: GET_ASSETS, validate: { query: createRouteValidationFunction(getAssetsQueryOptionsRT), }, @@ -102,10 +102,10 @@ export function assetsRoutes({ router }: SetupR }); } - const esClient = await getEsClientFromContext(context); + const { elasticsearchClient } = await getClientsFromContext(context); try { - const results = await getAssets({ esClient, size, filters }); + const results = await getAssets({ elasticsearchClient, size, filters }); return res.ok({ body: { results } }); } catch (error: unknown) { debug('error looking up asset records', error); @@ -120,7 +120,7 @@ export function assetsRoutes({ router }: SetupR // GET assets/related router.get( { - path: `${ASSET_MANAGER_API_BASE}/assets/related`, + path: GET_RELATED_ASSETS, validate: { query: createRouteValidationFunction(getRelatedAssetsQueryOptionsRT), }, @@ -129,7 +129,7 @@ export function assetsRoutes({ router }: SetupR // Add references into sample data and write integration tests const { from, to, ean, relation, maxDistance, size, type, kind } = req.query || {}; - const esClient = await getEsClientFromContext(context); + const { elasticsearchClient } = await getClientsFromContext(context); if (to && !isValidRange(from, to)) { return res.badRequest({ @@ -140,7 +140,7 @@ export function assetsRoutes({ router }: SetupR try { return res.ok({ body: { - results: await getAllRelatedAssets(esClient, { + results: await getAllRelatedAssets(elasticsearchClient, { ean, from, to, @@ -165,7 +165,7 @@ export function assetsRoutes({ router }: SetupR // GET /assets/diff router.get( { - path: `${ASSET_MANAGER_API_BASE}/assets/diff`, + path: GET_ASSETS_DIFF, validate: { query: createRouteValidationFunction(getAssetsDiffQueryOptionsRT), }, @@ -187,11 +187,11 @@ export function assetsRoutes({ router }: SetupR }); } - const esClient = await getEsClientFromContext(context); + const { elasticsearchClient } = await getClientsFromContext(context); try { const resultsForA = await getAssets({ - esClient, + elasticsearchClient, filters: { from: aFrom, to: aTo, @@ -201,7 +201,7 @@ export function assetsRoutes({ router }: SetupR }); const resultsForB = await getAssets({ - esClient, + elasticsearchClient, filters: { from: bFrom, to: bTo, diff --git a/x-pack/plugins/asset_manager/server/routes/assets/services.ts b/x-pack/plugins/asset_manager/server/routes/assets/services.ts index d7edf3b6f7f3c..3852a0bb60d11 100644 --- a/x-pack/plugins/asset_manager/server/routes/assets/services.ts +++ b/x-pack/plugins/asset_manager/server/routes/assets/services.ts @@ -17,8 +17,8 @@ import { import { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; import { debug } from '../../../common/debug_log'; import { SetupRouteOptions } from '../types'; -import { ASSET_MANAGER_API_BASE } from '../../constants'; -import { getEsClientFromContext } from '../utils'; +import { ASSET_MANAGER_API_BASE } from '../../../common/constants_routes'; +import { getClientsFromContext } from '../utils'; const sizeRT = rt.union([inRangeFromStringRt(1, 100), createLiteralValueFromUndefinedRT(10)]); const assetDateRT = rt.union([dateRt, datemathStringRt]); @@ -35,7 +35,7 @@ export type GetServiceAssetsQueryOptions = rt.TypeOf({ router, - assetAccessor, + assetClient, }: SetupRouteOptions) { // GET /assets/services router.get( @@ -47,16 +47,14 @@ export function servicesRoutes({ }, async (context, req, res) => { const { from = 'now-24h', to = 'now', parent } = req.query || {}; - const esClient = await getEsClientFromContext(context); - const coreContext = await context.core; - const soClient = coreContext.savedObjects.client; + const { elasticsearchClient, savedObjectsClient } = await getClientsFromContext(context); try { - const response = await assetAccessor.getServices({ + const response = await assetClient.getServices({ from: datemath.parse(from)!.toISOString(), to: datemath.parse(to)!.toISOString(), parent, - esClient, - soClient, + elasticsearchClient, + savedObjectsClient, }); return res.ok({ body: response }); diff --git a/x-pack/plugins/asset_manager/server/routes/index.ts b/x-pack/plugins/asset_manager/server/routes/index.ts index cab0b1558fa00..30064a8562b6f 100644 --- a/x-pack/plugins/asset_manager/server/routes/index.ts +++ b/x-pack/plugins/asset_manager/server/routes/index.ts @@ -15,11 +15,11 @@ import { servicesRoutes } from './assets/services'; export function setupRoutes({ router, - assetAccessor, + assetClient, }: SetupRouteOptions) { - pingRoute({ router, assetAccessor }); - assetsRoutes({ router, assetAccessor }); - sampleAssetsRoutes({ router, assetAccessor }); - hostsRoutes({ router, assetAccessor }); - servicesRoutes({ router, assetAccessor }); + pingRoute({ router, assetClient }); + assetsRoutes({ router, assetClient }); + sampleAssetsRoutes({ router, assetClient }); + hostsRoutes({ router, assetClient }); + servicesRoutes({ router, assetClient }); } diff --git a/x-pack/plugins/asset_manager/server/routes/ping.ts b/x-pack/plugins/asset_manager/server/routes/ping.ts index 3f7b1bb679b98..3d7a20b5fd476 100644 --- a/x-pack/plugins/asset_manager/server/routes/ping.ts +++ b/x-pack/plugins/asset_manager/server/routes/ping.ts @@ -6,7 +6,7 @@ */ import { RequestHandlerContextBase } from '@kbn/core-http-server'; -import { ASSET_MANAGER_API_BASE } from '../constants'; +import { ASSET_MANAGER_API_BASE } from '../../common/constants_routes'; import { SetupRouteOptions } from './types'; export function pingRoute({ router }: SetupRouteOptions) { diff --git a/x-pack/plugins/asset_manager/server/routes/sample_assets.ts b/x-pack/plugins/asset_manager/server/routes/sample_assets.ts index 98f7f32051f3f..447051bbb2730 100644 --- a/x-pack/plugins/asset_manager/server/routes/sample_assets.ts +++ b/x-pack/plugins/asset_manager/server/routes/sample_assets.ts @@ -7,11 +7,11 @@ import { schema } from '@kbn/config-schema'; import { RequestHandlerContext } from '@kbn/core/server'; -import { ASSET_MANAGER_API_BASE } from '../constants'; +import { ASSET_MANAGER_API_BASE } from '../../common/constants_routes'; import { getSampleAssetDocs, sampleAssets } from '../lib/sample_assets'; import { writeAssets } from '../lib/write_assets'; import { SetupRouteOptions } from './types'; -import { getEsClientFromContext } from './utils'; +import { getClientsFromContext } from './utils'; export type WriteSamplesPostBody = { baseDateTime?: string | number; @@ -62,12 +62,12 @@ export function sampleAssetsRoutes({ }, }); } - const esClient = await getEsClientFromContext(context); + const { elasticsearchClient } = await getClientsFromContext(context); const assetDocs = getSampleAssetDocs({ baseDateTime: parsed, excludeEans }); try { const response = await writeAssets({ - esClient, + elasticsearchClient, assetDocs, namespace: 'sample_data', refresh, @@ -101,9 +101,9 @@ export function sampleAssetsRoutes({ validate: {}, }, async (context, req, res) => { - const esClient = await getEsClientFromContext(context); + const { elasticsearchClient } = await getClientsFromContext(context); - const sampleDataStreams = await esClient.indices.getDataStream({ + const sampleDataStreams = await elasticsearchClient.indices.getDataStream({ name: 'assets-*-sample_data', expand_wildcards: 'all', }); @@ -115,7 +115,7 @@ export function sampleAssetsRoutes({ for (let i = 0; i < dataStreamsToDelete.length; i++) { const dsName = dataStreamsToDelete[i]; try { - await esClient.indices.deleteDataStream({ name: dsName }); + await elasticsearchClient.indices.deleteDataStream({ name: dsName }); deletedDataStreams.push(dsName); } catch (error: any) { errorWhileDeleting = diff --git a/x-pack/plugins/asset_manager/server/routes/types.ts b/x-pack/plugins/asset_manager/server/routes/types.ts index 2a0cf91f47df7..ae1b967a5b596 100644 --- a/x-pack/plugins/asset_manager/server/routes/types.ts +++ b/x-pack/plugins/asset_manager/server/routes/types.ts @@ -6,9 +6,9 @@ */ import { IRouter, RequestHandlerContextBase } from '@kbn/core-http-server'; -import { AssetAccessor } from '../lib/asset_accessor'; +import { AssetClient } from '../lib/asset_client'; export interface SetupRouteOptions { router: IRouter; - assetAccessor: AssetAccessor; + assetClient: AssetClient; } diff --git a/x-pack/plugins/asset_manager/server/routes/utils.ts b/x-pack/plugins/asset_manager/server/routes/utils.ts index 378ed0b48fc87..665adc0917fa0 100644 --- a/x-pack/plugins/asset_manager/server/routes/utils.ts +++ b/x-pack/plugins/asset_manager/server/routes/utils.ts @@ -7,6 +7,12 @@ import { RequestHandlerContext } from '@kbn/core/server'; -export async function getEsClientFromContext(context: T) { - return (await context.core).elasticsearch.client.asCurrentUser; +export async function getClientsFromContext(context: T) { + const coreContext = await context.core; + + return { + coreContext, + elasticsearchClient: coreContext.elasticsearch.client.asCurrentUser, + savedObjectsClient: coreContext.savedObjects.client, + }; } diff --git a/x-pack/plugins/asset_manager/server/types.ts b/x-pack/plugins/asset_manager/server/types.ts index 380d48aa0c7fe..431378c7c9a9f 100644 --- a/x-pack/plugins/asset_manager/server/types.ts +++ b/x-pack/plugins/asset_manager/server/types.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { schema, TypeOf } from '@kbn/config-schema'; -import { ElasticsearchClient } from '@kbn/core/server'; +import { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; import { ApmDataAccessPluginSetup, ApmDataAccessPluginStart, @@ -14,37 +13,9 @@ import { import { MetricsDataPluginSetup } from '@kbn/metrics-data-access-plugin/server'; export interface ElasticsearchAccessorOptions { - esClient: ElasticsearchClient; + elasticsearchClient: ElasticsearchClient; } -export const INDEX_DEFAULTS = { - logs: 'filebeat-*,logs-*', -}; - -export const configSchema = schema.object({ - alphaEnabled: schema.maybe(schema.boolean()), - // Designate where various types of data live. - // NOTE: this should be handled in a centralized way for observability, so - // that when a user configures these differently from the known defaults, - // that value is propagated everywhere. For now, we duplicate the value here. - sourceIndices: schema.object( - { - logs: schema.string({ defaultValue: INDEX_DEFAULTS.logs }), - }, - { defaultValue: INDEX_DEFAULTS } - ), - // Choose an explicit source for asset queries. - // NOTE: This will eventually need to be able to cleverly switch - // between these values based on the availability of data in the - // indices, and possibly for each asset kind/type value. - // For now, we set this explicitly. - lockedSource: schema.oneOf([schema.literal('assets'), schema.literal('signals')], { - defaultValue: 'signals', - }), -}); - -export type AssetManagerConfig = TypeOf; - export interface AssetManagerPluginSetupDependencies { apmDataAccess: ApmDataAccessPluginSetup; metricsDataAccess: MetricsDataPluginSetup; @@ -52,3 +23,8 @@ export interface AssetManagerPluginSetupDependencies { export interface AssetManagerPluginStartDependencies { apmDataAccess: ApmDataAccessPluginStart; } + +export interface AssetClientDependencies { + elasticsearchClient: ElasticsearchClient; + savedObjectsClient: SavedObjectsClientContract; +} diff --git a/x-pack/plugins/asset_manager/tsconfig.json b/x-pack/plugins/asset_manager/tsconfig.json index d7663856f0513..35972189e5287 100644 --- a/x-pack/plugins/asset_manager/tsconfig.json +++ b/x-pack/plugins/asset_manager/tsconfig.json @@ -7,6 +7,7 @@ "../../../typings/**/*", "common/**/*", "server/**/*", + "public/**/*", "types/**/*" ], "exclude": ["target/**/*"], @@ -17,10 +18,11 @@ "@kbn/core-http-server", "@kbn/core-elasticsearch-client-server-mocks", "@kbn/io-ts-utils", - "@kbn/core-elasticsearch-server", "@kbn/core-http-request-handler-context-server", "@kbn/datemath", "@kbn/apm-data-access-plugin", + "@kbn/core-http-browser-mocks", + "@kbn/logging", "@kbn/metrics-data-access-plugin" ] } diff --git a/x-pack/plugins/cloud_security_posture/public/common/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/constants.ts index eb6318e7c6727..bec25a70dbd1e 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/constants.ts @@ -93,7 +93,6 @@ export const cloudPostureIntegrations: CloudPostureIntegrations = { defaultMessage: 'CIS GCP', }), icon: googleCloudLogo, - isBeta: true, }, // needs to be a function that disables/enabled based on integration version { diff --git a/x-pack/plugins/cloud_security_posture/public/components/csp_counter_card.tsx b/x-pack/plugins/cloud_security_posture/public/components/csp_counter_card.tsx index 9b07c0f3edded..e41ead46f52af 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/csp_counter_card.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/csp_counter_card.tsx @@ -17,11 +17,17 @@ export interface CspCounterCardProps { description: EuiStatProps['description']; } -export const CspCounterCard = (counter: CspCounterCardProps) => { +export const CspCounterCard = ({ + id, + button, + title, + titleColor, + description, +}: CspCounterCardProps) => { const { euiTheme } = useEuiTheme(); return ( - + { }, }} titleSize="s" - title={counter.title} - titleColor={counter.titleColor} + title={title} + titleColor={titleColor} descriptionElement="h6" - description={counter.description} + description={description} /> - {counter.button} + {button} ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/gcp_credential_form.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/gcp_credential_form.tsx index 18ac1247d4b1a..411d5298580a5 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/gcp_credential_form.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/gcp_credential_form.tsx @@ -25,6 +25,7 @@ import type { NewPackagePolicy } from '@kbn/fleet-plugin/public'; import { NewPackagePolicyInput, PackageInfo } from '@kbn/fleet-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { GcpCredentialsType } from '../../../common/types'; import { CLOUDBEAT_GCP, SETUP_ACCESS_CLOUD_SHELL, @@ -39,10 +40,12 @@ import { import { MIN_VERSION_GCP_CIS } from '../../common/constants'; import { cspIntegrationDocsNavigation } from '../../common/navigation/constants'; import { ReadDocumentation } from './aws_credentials_form/aws_credentials_form'; +import { GCP_ORGANIZATION_ACCOUNT } from './policy_template_form'; export const CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS = { GOOGLE_CLOUD_SHELL_SETUP: 'google_cloud_shell_setup_test_id', PROJECT_ID: 'project_id_test_id', + ORGANIZATION_ID: 'organization_id_test_id', CREDENTIALS_TYPE: 'credentials_type_test_id', CREDENTIALS_FILE: 'credentials_file_test_id', CREDENTIALS_JSON: 'credentials_json_test_id', @@ -71,7 +74,21 @@ const GCPSetupInfoContent = () => ( ); -const GoogleCloudShellSetup = () => { +const GoogleCloudShellSetup = ({ + fields, + onChange, + input, +}: { + fields: Array; + onChange: (key: string, value: string) => void; + input: NewPackagePolicyInput; +}) => { + const accountType = input.streams?.[0]?.vars?.['gcp.account_type']?.value; + const getFieldById = (id: keyof GcpInputFields['fields']) => { + return fields.find((element) => element.id === id); + }; + const projectIdFields = getFieldById('gcp.project_id'); + const organizationIdFields = getFieldById('gcp.organization_id'); return ( <> { defaultMessage="Log into your Google Cloud Console" /> -
  • - -
  • + {accountType === GCP_ORGANIZATION_ACCOUNT ? ( +
  • + +
  • + ) : ( +
  • + +
  • + )} +
  • { + + {organizationIdFields && accountType === GCP_ORGANIZATION_ACCOUNT && ( + + onChange(organizationIdFields.id, event.target.value)} + /> + + )} + {projectIdFields && ( + + onChange(projectIdFields.id, event.target.value)} + /> + + )} + + ); }; @@ -137,6 +189,12 @@ interface GcpInputFields { export const gcpField: GcpInputFields = { fields: { + 'gcp.organization_id': { + label: i18n.translate('xpack.csp.gcpIntegration.organizationIdFieldLabel', { + defaultMessage: 'Organization ID', + }), + type: 'text', + }, 'gcp.project_id': { label: i18n.translate('xpack.csp.gcpIntegration.projectidFieldLabel', { defaultMessage: 'Project ID', @@ -190,17 +248,14 @@ const getSetupFormatOptions = (): Array<{ interface GcpFormProps { newPolicy: NewPackagePolicy; - input: Extract< - NewPackagePolicyPostureInput, - { type: 'cloudbeat/cis_aws' | 'cloudbeat/cis_eks' | 'cloudbeat/cis_gcp' } - >; + input: Extract; updatePolicy(updatedPolicy: NewPackagePolicy): void; packageInfo: PackageInfo; setIsValid: (isValid: boolean) => void; onChange: any; } -const getInputVarsFields = (input: NewPackagePolicyInput, fields: GcpFields) => +export const getInputVarsFields = (input: NewPackagePolicyInput, fields: GcpFields) => Object.entries(input.streams[0].vars || {}) .filter(([id]) => id in fields) .map(([id, inputVar]) => { @@ -290,6 +345,10 @@ const useCloudShellUrl = ({ }, [newPolicy?.vars?.cloud_shell_url, newPolicy, packageInfo, setupFormat]); }; +export const getGcpCredentialsType = ( + input: Extract +): GcpCredentialsType | undefined => input.streams[0].vars?.setup_access.value; + export const GcpCredentialsForm = ({ input, newPolicy, @@ -298,6 +357,12 @@ export const GcpCredentialsForm = ({ setIsValid, onChange, }: GcpFormProps) => { + /* Create a subset of properties from GcpField to use for hiding value of credentials json and credentials file when user switch from Manual to Cloud Shell, we wanna keep Project and Organization ID */ + const subsetOfGcpField = (({ ['gcp.credentials.file']: a, ['gcp.credentials.json']: b }) => ({ + 'gcp.credentials.file': a, + ['gcp.credentials.json']: b, + }))(gcpField.fields); + const fieldsToHide = getInputVarsFields(input, subsetOfGcpField); const fields = getInputVarsFields(input, gcpField.fields); const validSemantic = semverValid(packageInfo.version); const integrationVersionNumberOnly = semverCoerce(validSemantic) || ''; @@ -305,9 +370,20 @@ export const GcpCredentialsForm = ({ const fieldsSnapshot = useRef({}); const lastSetupAccessType = useRef(undefined); const setupFormat = getSetupFormatFromInput(input); - const getFieldById = (id: keyof GcpInputFields['fields']) => { - return fields.find((element) => element.id === id); - }; + const accountType = input.streams?.[0]?.vars?.['gcp.account_type']?.value; + const isOrganization = accountType === 'organization-account'; + // Integration is Invalid IF Version is not at least 1.5.0 OR Setup Access is manual but Project ID is empty + useEffect(() => { + const isInvalidPolicy = isInvalid; + + setIsValid(!isInvalidPolicy); + + onChange({ + isValid: !isInvalidPolicy, + updatedPolicy: newPolicy, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [setupFormat, input.type]); useCloudShellUrl({ packageInfo, @@ -316,23 +392,23 @@ export const GcpCredentialsForm = ({ setupFormat, }); const onSetupFormatChange = (newSetupFormat: SetupFormatGCP) => { - if (newSetupFormat === SETUP_ACCESS_CLOUD_SHELL) { + if (newSetupFormat === 'google_cloud_shell') { // We need to store the current manual fields to restore them later fieldsSnapshot.current = Object.fromEntries( - fields.map((field) => [field.id, { value: field.value }]) + fieldsToHide.map((field) => [field.id, { value: field.value }]) ); // We need to store the last manual credentials type to restore it later - lastSetupAccessType.current = input.streams[0].vars?.setup_access?.value; + lastSetupAccessType.current = getGcpCredentialsType(input); updatePolicy( getPosturePolicy(newPolicy, input.type, { setup_access: { - value: SETUP_ACCESS_CLOUD_SHELL, + value: 'google_cloud_shell', type: 'text', }, // Clearing fields from previous setup format to prevent exposing credentials // when switching from manual to cloud formation - ...Object.fromEntries(fields.map((field) => [field.id, { value: undefined }])), + ...Object.fromEntries(fieldsToHide.map((field) => [field.id, { value: undefined }])), }) ); } else { @@ -340,7 +416,7 @@ export const GcpCredentialsForm = ({ getPosturePolicy(newPolicy, input.type, { setup_access: { // Restoring last manual credentials type - value: SETUP_ACCESS_MANUAL, + value: lastSetupAccessType.current || SETUP_ACCESS_MANUAL, type: 'text', }, // Restoring fields from manual setup format if any @@ -349,20 +425,6 @@ export const GcpCredentialsForm = ({ ); } }; - // Integration is Invalid IF Version is not at least 1.5.0 OR Setup Access is manual but Project ID is empty - useEffect(() => { - const isProjectIdEmpty = - setupFormat === SETUP_ACCESS_MANUAL && !getFieldById('gcp.project_id')?.value; - const isInvalidPolicy = isInvalid || isProjectIdEmpty; - - setIsValid(!isInvalidPolicy); - - onChange({ - isValid: !isInvalidPolicy, - updatedPolicy: newPolicy, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [input, packageInfo, setupFormat]); if (isInvalid) { return ( @@ -385,19 +447,29 @@ export const GcpCredentialsForm = ({ size="s" options={getSetupFormatOptions()} idSelected={setupFormat} - onChange={onSetupFormatChange} + onChange={(idSelected: SetupFormatGCP) => + idSelected !== setupFormat && onSetupFormatChange(idSelected) + } /> - {setupFormat === SETUP_ACCESS_MANUAL ? ( - updatePolicy(getPosturePolicy(newPolicy, input.type, { [key]: { value } })) } + input={input} /> ) : ( - + + updatePolicy(getPosturePolicy(newPolicy, input.type, { [key]: { value } })) + } + isOrganization={isOrganization} + /> )} + @@ -408,13 +480,18 @@ export const GcpCredentialsForm = ({ const GcpInputVarFields = ({ fields, onChange, + isOrganization, }: { fields: Array; onChange: (key: string, value: string) => void; + isOrganization: boolean; }) => { const getFieldById = (id: keyof GcpInputFields['fields']) => { return fields.find((element) => element.id === id); }; + + const organizationIdFields = getFieldById('gcp.organization_id'); + const projectIdFields = getFieldById('gcp.project_id'); const credentialsTypeFields = getFieldById('gcp.credentials.type'); const credentialFilesFields = getFieldById('gcp.credentials.file'); @@ -428,6 +505,17 @@ const GcpInputVarFields = ({ return (
    + {organizationIdFields && isOrganization && ( + + onChange(organizationIdFields.id, event.target.value)} + /> + + )} {projectIdFields && ( ', () => { it(`renders Google Cloud Shell forms when Setup Access is set to Google Cloud Shell`, () => { let policy = getMockPolicyGCP(); policy = getPosturePolicy(policy, CLOUDBEAT_GCP, { - credentials_type: { value: 'credentials-file' }, + 'gcp.account_type': { value: GCP_ORGANIZATION_ACCOUNT }, setup_access: { value: 'google_cloud_shell' }, }); @@ -1028,31 +1030,6 @@ describe('', () => { ).toBeInTheDocument(); }); - it(`project ID is required for Manual users`, () => { - let policy = getMockPolicyGCP(); - policy = getPosturePolicy(policy, CLOUDBEAT_GCP, { - 'gcp.project_id': { value: undefined }, - setup_access: { value: 'manual' }, - }); - - const { rerender } = render( - - ); - expect(onChange).toHaveBeenCalledWith({ - isValid: false, - updatedPolicy: policy, - }); - policy = getPosturePolicy(policy, CLOUDBEAT_GCP, { - 'gcp.project_id': { value: '' }, - setup_access: { value: 'manual' }, - }); - rerender(); - expect(onChange).toHaveBeenCalledWith({ - isValid: false, - updatedPolicy: policy, - }); - }); - it(`renders ${CLOUDBEAT_GCP} Credentials File fields`, () => { let policy = getMockPolicyGCP(); policy = getPosturePolicy(policy, CLOUDBEAT_GCP, { @@ -1136,6 +1113,96 @@ describe('', () => { updatedPolicy: policy, }); }); + + it(`${CLOUDBEAT_GCP} form do not displays upgrade message for supported versions and gcp organization option is enabled`, () => { + let policy = getMockPolicyGCP(); + policy = getPosturePolicy(policy, CLOUDBEAT_GCP, { + 'gcp.credentials.type': { value: 'manual' }, + 'gcp.account_type': { value: GCP_ORGANIZATION_ACCOUNT }, + }); + + const { queryByText, getByLabelText } = render( + + ); + + expect( + queryByText( + 'GCP Organization not supported in current integration version. Please upgrade to the latest version to enable GCP Organizations integration.' + ) + ).not.toBeInTheDocument(); + expect(getByLabelText('GCP Organization')).toBeEnabled(); + }); + + it(`renders ${CLOUDBEAT_GCP} Organization fields when account type is Organization and Setup Access is Google Cloud Shell`, () => { + let policy = getMockPolicyGCP(); + policy = getPosturePolicy(policy, CLOUDBEAT_GCP, { + 'gcp.account_type': { value: GCP_ORGANIZATION_ACCOUNT }, + setup_access: { value: 'google_cloud_shell' }, + }); + + const { getByLabelText, getByTestId } = render( + + ); + + expect(getByTestId(CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS.ORGANIZATION_ID)).toBeInTheDocument(); + + expect(getByLabelText('Organization ID')).toBeInTheDocument(); + }); + + it(`renders ${CLOUDBEAT_GCP} Organization fields when account type is Organization and Setup Access is manual`, () => { + let policy = getMockPolicyGCP(); + policy = getPosturePolicy(policy, CLOUDBEAT_GCP, { + 'gcp.account_type': { value: GCP_ORGANIZATION_ACCOUNT }, + setup_access: { value: 'manual' }, + }); + + const { getByLabelText, getByTestId } = render( + + ); + + expect(getByTestId(CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS.ORGANIZATION_ID)).toBeInTheDocument(); + + expect(getByLabelText('Organization ID')).toBeInTheDocument(); + }); + + it(`Should not render ${CLOUDBEAT_GCP} Organization fields when account type is Single`, () => { + let policy = getMockPolicyGCP(); + policy = getPosturePolicy(policy, CLOUDBEAT_GCP, { + 'gcp.account_type': { value: GCP_SINGLE_ACCOUNT }, + setup_access: { value: 'google_cloud_shell' }, + }); + + const { queryByLabelText, queryByTestId } = render( + + ); + + expect(queryByTestId(CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS.ORGANIZATION_ID)).toBeNull(); + + expect(queryByLabelText('Organization ID')).toBeNull(); + }); + + it(`updates ${CLOUDBEAT_GCP} organization id`, () => { + let policy = getMockPolicyGCP(); + policy = getPosturePolicy(policy, CLOUDBEAT_GCP, { + 'gcp.account_type': { value: GCP_ORGANIZATION_ACCOUNT }, + setup_access: { value: 'manual' }, + }); + + const { getByTestId } = render( + + ); + + userEvent.type(getByTestId(CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS.ORGANIZATION_ID), 'c'); + + policy = getPosturePolicy(policy, CLOUDBEAT_GCP, { + 'gcp.organization_id': { value: 'c' }, + }); + + expect(onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: policy, + }); + }); }); describe('Azure Credentials input fields', () => { diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx index 67a617decac76..306cc6da445fd 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx @@ -4,9 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import semverCompare from 'semver/functions/compare'; import semverValid from 'semver/functions/valid'; +import semverCoerce from 'semver/functions/coerce'; +import semverLt from 'semver/functions/lt'; import { EuiCallOut, EuiFieldText, @@ -54,6 +56,7 @@ import { PolicyTemplateVarsForm, } from './policy_template_selectors'; import { usePackagePolicyList } from '../../common/api/use_package_policy_list'; +import { gcpField, getInputVarsFields } from './gcp_credential_form'; const DEFAULT_INPUT_TYPE = { kspm: CLOUDBEAT_VANILLA, @@ -82,13 +85,14 @@ interface IntegrationInfoFieldsProps { export const AWS_SINGLE_ACCOUNT = 'single-account'; export const AWS_ORGANIZATION_ACCOUNT = 'organization-account'; -export const GCP_SINGLE_ACCOUNT = 'single-account-gcp'; -export const GCP_ORGANIZATION_ACCOUNT = 'organization-account-gcp'; +export const GCP_SINGLE_ACCOUNT = 'single-account'; +export const GCP_ORGANIZATION_ACCOUNT = 'organization-account'; export const AZURE_SINGLE_ACCOUNT = 'single-account-azure'; export const AZURE_ORGANIZATION_ACCOUNT = 'organization-account-azure'; type AwsAccountType = typeof AWS_SINGLE_ACCOUNT | typeof AWS_ORGANIZATION_ACCOUNT; type AzureAccountType = typeof AZURE_SINGLE_ACCOUNT | typeof AZURE_ORGANIZATION_ACCOUNT; +type GcpAccountType = typeof GCP_SINGLE_ACCOUNT | typeof GCP_ORGANIZATION_ACCOUNT; const getAwsAccountTypeOptions = (isAwsOrgDisabled: boolean): CspRadioGroupProps['options'] => [ { @@ -111,19 +115,18 @@ const getAwsAccountTypeOptions = (isAwsOrgDisabled: boolean): CspRadioGroupProps }, ]; -const getGcpAccountTypeOptions = (): CspRadioGroupProps['options'] => [ +const getGcpAccountTypeOptions = (isGcpOrgDisabled: boolean): CspRadioGroupProps['options'] => [ { id: GCP_ORGANIZATION_ACCOUNT, label: i18n.translate('xpack.csp.fleetIntegration.gcpAccountType.gcpOrganizationLabel', { defaultMessage: 'GCP Organization', }), - disabled: true, - tooltip: i18n.translate( - 'xpack.csp.fleetIntegration.gcpAccountType.gcpOrganizationDisabledTooltip', - { - defaultMessage: 'Coming Soon', - } - ), + disabled: isGcpOrgDisabled, + tooltip: isGcpOrgDisabled + ? i18n.translate('xpack.csp.fleetIntegration.gcpAccountType.gcpOrganizationDisabledTooltip', { + defaultMessage: 'Supported from integration version 1.6.0 and above', + }) + : undefined, }, { id: GCP_SINGLE_ACCOUNT, @@ -258,6 +261,12 @@ const AwsAccountTypeSelect = ({ ); }; +const getGcpAccountType = ( + input: Extract +): GcpAccountType | undefined => input.streams[0].vars?.['gcp.account_type']?.value; + +const GCP_ORG_MINIMUM_PACKAGE_VERSION = '1.6.0'; + const GcpAccountTypeSelect = ({ input, newPolicy, @@ -269,6 +278,71 @@ const GcpAccountTypeSelect = ({ updatePolicy: (updatedPolicy: NewPackagePolicy) => void; packageInfo: PackageInfo; }) => { + // This will disable the gcp org option for any version below 1.6.0 which introduced support for account_type. https://github.com/elastic/integrations/pull/6682 + const validSemantic = semverValid(packageInfo.version); + const integrationVersionNumberOnly = semverCoerce(validSemantic) || ''; + const isGcpOrgDisabled = semverLt(integrationVersionNumberOnly, GCP_ORG_MINIMUM_PACKAGE_VERSION); + + const gcpAccountTypeOptions = useMemo( + () => getGcpAccountTypeOptions(isGcpOrgDisabled), + [isGcpOrgDisabled] + ); + /* Create a subset of properties from GcpField to use for hiding value of Organization ID when switching account type from Organization to Single */ + const subsetOfGcpField = (({ ['gcp.organization_id']: a }) => ({ 'gcp.organization_id': a }))( + gcpField.fields + ); + const fieldsToHide = getInputVarsFields(input, subsetOfGcpField); + const fieldsSnapshot = useRef({}); + const lastSetupAccessType = useRef(undefined); + const onSetupFormatChange = (newSetupFormat: string) => { + if (newSetupFormat === 'single-account') { + // We need to store the current manual fields to restore them later + fieldsSnapshot.current = Object.fromEntries( + fieldsToHide.map((field) => [field.id, { value: field.value }]) + ); + // We need to store the last manual credentials type to restore it later + lastSetupAccessType.current = input.streams[0].vars?.['gcp.account_type'].value; + + updatePolicy( + getPosturePolicy(newPolicy, input.type, { + 'gcp.account_type': { + value: 'single-account', + type: 'text', + }, + // Clearing fields from previous setup format to prevent exposing credentials + // when switching from manual to cloud formation + ...Object.fromEntries(fieldsToHide.map((field) => [field.id, { value: undefined }])), + }) + ); + } else { + updatePolicy( + getPosturePolicy(newPolicy, input.type, { + 'gcp.account_type': { + // Restoring last manual credentials type + value: lastSetupAccessType.current || 'organization-account', + type: 'text', + }, + // Restoring fields from manual setup format if any + ...fieldsSnapshot.current, + }) + ); + } + }; + + useEffect(() => { + if (!getGcpAccountType(input)) { + updatePolicy( + getPosturePolicy(newPolicy, input.type, { + 'gcp.account_type': { + value: isGcpOrgDisabled ? GCP_SINGLE_ACCOUNT : GCP_ORGANIZATION_ACCOUNT, + type: 'text', + }, + }) + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [input]); + return ( <> @@ -278,28 +352,47 @@ const GcpAccountTypeSelect = ({ /> + {isGcpOrgDisabled && ( + <> + + + + + + )} { - updatePolicy( - getPosturePolicy(newPolicy, input.type, { - gcp_account_type: { - value: accountType, - type: 'text', - }, - }) - ); - }} + idSelected={getGcpAccountType(input) || ''} + options={gcpAccountTypeOptions} + onChange={(accountType) => + accountType !== getGcpAccountType(input) && onSetupFormatChange(accountType) + } size="m" /> - - - - + {getGcpAccountType(input) === GCP_ORGANIZATION_ACCOUNT && ( + <> + + + + + + )} + {getGcpAccountType(input) === GCP_SINGLE_ACCOUNT && ( + <> + + + + + + )} ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/components/vuln_counter_card.tsx b/x-pack/plugins/cloud_security_posture/public/components/vuln_counter_card.tsx new file mode 100644 index 0000000000000..5344ccd597d5f --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/vuln_counter_card.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { MouseEventHandler } from 'react'; +import { EuiPanel, EuiStat, useEuiTheme, EuiIcon } from '@elastic/eui'; +import type { EuiStatProps } from '@elastic/eui'; +import { css } from '@emotion/react'; + +export interface VulnCounterCardProps { + id: string; + title: EuiStatProps['title']; + titleColor?: EuiStatProps['titleColor']; + description: EuiStatProps['description']; + onClick?: MouseEventHandler; +} + +export const VulnCounterCard = ({ + id, + title, + titleColor, + description, + onClick, +}: VulnCounterCardProps) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + {onClick && ( + + )} + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_statistics.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_statistics.tsx index 28abc994539d0..1552ed2821bb8 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_statistics.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_statistics.tsx @@ -7,18 +7,18 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiHealth } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { VulnCounterCard, type VulnCounterCardProps } from '../../components/vuln_counter_card'; import { VULNERABILITIES_SEVERITY } from '../../../common/constants'; import { useVulnerabilityDashboardApi } from '../../common/api/use_vulnerability_dashboard_api'; import { useNavigateVulnerabilities } from '../../common/hooks/use_navigate_findings'; import { CompactFormattedNumber } from '../../components/compact_formatted_number'; import { getSeverityStatusColor } from '../../common/utils/get_vulnerability_colors'; -import { CspCounterCard } from '../../components/csp_counter_card'; export const VulnerabilityStatistics = () => { const navToVulnerabilities = useNavigateVulnerabilities(); const getVulnerabilityDashboard = useVulnerabilityDashboardApi(); - const stats = useMemo( + const stats: VulnCounterCardProps[] = useMemo( () => [ { id: 'critical-count-stat', @@ -110,7 +110,7 @@ export const VulnerabilityStatistics = () => { {stats.map((stat) => ( - + ))} diff --git a/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts b/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts index 7b8e7b6a585c4..5ed2c6893be5f 100644 --- a/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts +++ b/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts @@ -59,6 +59,7 @@ export const DEFAULT_INITIAL_APP_DATA = { organization: { name: 'ACME Donuts', defaultOrgName: 'My Organization', + kibanaUIsEnabled: false, }, account: { id: 'some-id-string', diff --git a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts index 7f30c132076d3..68643436235bb 100644 --- a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts +++ b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts @@ -14,13 +14,14 @@ export interface Account { } export interface Organization { - name: string; defaultOrgName: string; + kibanaUIsEnabled: boolean; + name: string; } export interface WorkplaceSearchInitialData { - organization: Organization; account: Account; + organization: Organization; } export interface ConfiguredLimits { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts index 5731a9a829e83..25829856d5d1a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts @@ -41,6 +41,7 @@ describe('AppLogic', () => { }, organization: { defaultOrgName: 'My Organization', + kibanaUIsEnabled: false, name: 'ACME Donuts', }, }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index f68d1cb3b3d0b..453a6346f8690 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -89,8 +89,8 @@ describe('WorkplaceSearchConfigured', () => { }); it('renders chrome and header actions', () => { + setMockValues({ organization: { kibanaUIsEnabled: false } }); const wrapper = shallow(); - expect(wrapper.find(Overview)).toHaveLength(1); expect(mockKibanaValues.setChromeIsVisible).toHaveBeenCalledWith(true); @@ -99,13 +99,14 @@ describe('WorkplaceSearchConfigured', () => { it('initializes app data with passed props', () => { const { workplaceSearch } = DEFAULT_INITIAL_APP_DATA; + setMockValues({ organization: { kibanaUIsEnabled: false } }); shallow(); expect(initializeAppData).toHaveBeenCalledWith({ workplaceSearch }); }); it('does not re-initialize app data or re-render header actions', () => { - setMockValues({ hasInitialized: true }); + setMockValues({ hasInitialized: true, organization: { kibanaUIsEnabled: false } }); shallow(); @@ -114,8 +115,14 @@ describe('WorkplaceSearchConfigured', () => { }); it('renders SourceAdded', () => { + setMockValues({ organization: { kibanaUIsEnabled: true } }); const wrapper = shallow(); expect(wrapper.find(SourceAdded)).toHaveLength(1); }); + it('renders Overview when kibanaUIsEnabled is true', () => { + setMockValues({ organization: { kibanaUIsEnabled: false } }); + const wrapper = shallow(); + expect(wrapper.find(Overview)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index d6dc435efd9a3..74921301669e7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -74,7 +74,10 @@ export const WorkplaceSearch: React.FC = (props) => { }; export const WorkplaceSearchConfigured: React.FC = (props) => { - const { hasInitialized } = useValues(AppLogic); + const { + hasInitialized, + organization: { kibanaUIsEnabled }, + } = useValues(AppLogic); const { initializeAppData, setContext } = useActions(AppLogic); const { renderHeaderActions, setChromeIsVisible } = useValues(KibanaLogic); @@ -99,53 +102,58 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { return ( - - - - - - - - - - + {kibanaUIsEnabled && ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - + + - - + + - - + + - - - - - - - - - - - - - - - - - - - - + + + + + )} + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/index.ts index 69c843fe3821e..6e43c4e317075 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/index.ts @@ -6,3 +6,4 @@ */ export { Overview } from './overview'; +export { WorkplaceSearchGate } from './workplace_search_gate'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx index 7bd40d6f04a56..9322a9335cc60 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx @@ -17,6 +17,7 @@ import { OnboardingSteps } from './onboarding_steps'; import { OrganizationStats } from './organization_stats'; import { Overview } from './overview'; import { RecentActivity } from './recent_activity'; +import { WorkplaceSearchGatePage } from './workplace_search_gate_form'; describe('Overview', () => { it('calls initialize function', async () => { @@ -32,8 +33,19 @@ describe('Overview', () => { expect(wrapper.prop('pageHeader')).toBeUndefined(); }); - it('renders onboarding state', () => { - setMockValues({ dataLoading: false }); + it('does not render overview page when kibanaUIsEnabled is false', () => { + setMockValues({ dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(WorkplaceSearchGatePage)).toHaveLength(1); + expect(wrapper.find(OnboardingSteps)).toHaveLength(0); + expect(wrapper.find(OrganizationStats)).toHaveLength(0); + expect(wrapper.find(RecentActivity)).toHaveLength(0); + }); + + it('renders onboarding state when kibanaUIsEnabled is true', () => { + setMockValues({ dataLoading: false, organization: { kibanaUIsEnabled: true } }); + const wrapper = shallow(); expect(wrapper.find(OnboardingSteps)).toHaveLength(1); @@ -50,6 +62,7 @@ describe('Overview', () => { organization: { name: 'foo', defaultOrgName: 'bar', + kibanaUIsEnabled: true, }, }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx index c51fdb64b8f26..3bccec0cd66dd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx @@ -19,6 +19,7 @@ import { OnboardingSteps } from './onboarding_steps'; import { OrganizationStats } from './organization_stats'; import { OverviewLogic } from './overview_logic'; import { RecentActivity } from './recent_activity'; +import { WorkplaceSearchGatePage } from './workplace_search_gate_form'; const ONBOARDING_HEADER_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.title', @@ -41,7 +42,7 @@ const HEADER_DESCRIPTION = i18n.translate( export const Overview: React.FC = () => { const { - organization: { name: orgName, defaultOrgName }, + organization: { name: orgName, defaultOrgName, kibanaUIsEnabled }, } = useValues(AppLogic); const { initializeOverview } = useActions(OverviewLogic); @@ -56,7 +57,7 @@ export const Overview: React.FC = () => { const headerTitle = hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; - return ( + return kibanaUIsEnabled ? ( { + ) : ( + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/workplace_search_gate.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/workplace_search_gate.tsx new file mode 100644 index 0000000000000..46b32d2c129aa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/workplace_search_gate.tsx @@ -0,0 +1,677 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, { Fragment } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormLabel, + EuiFormRow, + EuiIcon, + EuiLink, + EuiPanel, + EuiSelect, + EuiSpacer, + EuiSuperSelect, + EuiText, + EuiTextArea, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { WorkplaceSearchGateLogic } from './workplace_search_gate_logic'; + +const getFeature = (id: string) => { + switch (id) { + case featuresList.searchApplication.id: + return featuresList.searchApplication; + case featuresList.contentSources.id: + return featuresList.contentSources; + case featuresList.contentExtraction.id: + return featuresList.contentExtraction; + case featuresList.documentLevelPermissions.id: + return featuresList.documentLevelPermissions; + case featuresList.synonyms.id: + return featuresList.synonyms; + case featuresList.analytics.id: + return featuresList.analytics; + } +}; +const featuresList = { + analytics: { + actionLabel: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.analytics.action.Label', + { + defaultMessage: 'Start with Behavioral Analytics', + } + ), + actionLink: './analytics ', + addOnLearnMoreLabel: undefined, + addOnLearnMoreUrl: undefined, + description: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.analytics.featureDescription', + { + defaultMessage: + "Did you know you can easily analyze your users' searching and clicking behavior with Behavioral Analytics? Instrument your website or application for tracking relevant user actions.", + } + ), + id: 'analytics', + learnMore: 'https://www.elastic.co/guide/en/enterprise-search/current/analytics-overview.html', + title: i18n.translate('xpack.enterpriseSearch.workplaceSearch.gateForm.analytics.featureName', { + defaultMessage: 'Use Behavioral Analytics', + }), + }, + contentExtraction: { + actionLabel: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.connectorExtraction.featureButtonLabel', + { + defaultMessage: 'Use a connector ', + } + ), + actionLink: './content/search_indices/new_index/select_connector', + addOnLearnMoreLabel: undefined, + addOnLearnMoreUrl: undefined, + + description: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.connectorExtraction.featureDescription', + { + defaultMessage: + 'Did you know you have access to powerful content extraction via Elastic connectors! Use our powerful and highly adaptable extraction capabilities to extract contents from your files. ', + } + ), + id: 'contentExtraction', + learnMore: 'https://www.elastic.co/guide/en/enterprise-search/current/connectors.html ', + title: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.connectorExtraction.featureName', + { + defaultMessage: 'Use Elastic connectors', + } + ), + }, + contentSources: { + actionLabel: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.connectorSources.featureButtonLabel', + { + defaultMessage: 'Use a connector ', + } + ), + actionLink: './content/search_indices/new_index/select_connector', + addOnLearnMoreLabel: undefined, + addOnLearnMoreUrl: undefined, + description: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.connectorSources.featureDescription', + { + defaultMessage: + 'Did you know Elastic connectors are now available? You can keep content on your data sources in sync with your search-optimized indices! ', + } + ), + id: 'contentSources', + learnMore: 'https://www.elastic.co/guide/en/enterprise-search/current/connectors.html ', + title: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.connectorSources.featureName', + { + defaultMessage: 'Use Elastic connectors', + } + ), + }, + documentLevelPermissions: { + actionLabel: undefined, + actionLink: undefined, + addOnLearnMoreLabel: undefined, + addOnLearnMoreUrl: undefined, + + description: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.docLevelPermissions.featureDescription', + { + defaultMessage: + 'Did you know you can restrict access to documents in your Elasticsearch indices according to user and group permissions? Return only authorized search results for users with Elastic’s document level security. ', + } + ), + id: 'documentLevelPermissions', + learnMore: 'https://www.elastic.co/guide/en/enterprise-search/current/dls.html', + title: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.docLevelPermissions.featureName', + { + defaultMessage: 'Use Elastic connectors', + } + ), + }, + searchApplication: { + actionLabel: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.searchApplication.action.Label', + { + defaultMessage: 'Create a Search Application', + } + ), + actionLink: './applications/search_applications', + addOnLearnMoreLabel: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.searchApplication.addOn.learnMoreLabel', + { + defaultMessage: 'Search UI', + } + ), + addOnLearnMoreUrl: 'https://www.elastic.co/guide/en/enterprise-search/current/search-ui.html ', + description: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.searchApplication.featureDescription', + { + defaultMessage: + 'Did you know you can restrict access to documents in your Elasticsearch indices according to user and group permissions? Return only authorized search results for users with Elastic’s document level security. ', + } + ), + id: 'searchApplication', + learnMore: 'https://www.elastic.co/guide/en/enterprise-search/current/search-applications.html', + title: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.searchApplication.featureName', + { + defaultMessage: 'Create Search Application', + } + ), + }, + synonyms: { + actionLabel: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.synonyms.action.Label', + { + defaultMessage: 'Search with synonyms', + } + ), + actionLink: + 'https://www.elastic.co/guide/en/elasticsearch/reference/current/search-with-synonyms.html ', + addOnLearnMoreLabel: undefined, + addOnLearnMoreUrl: undefined, + description: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.synonyms.featureDescription', + { + defaultMessage: + 'Did you know you can improve your search experience by searching with synonyms? Use our Synonyms API to easily create and manage synonym sets.', + } + ), + id: 'synonyms', + learnMore: 'https://www.elastic.co/guide/en/elasticsearch/reference/8.10/synonyms-apis.html', + title: i18n.translate('xpack.enterpriseSearch.workplaceSearch.gateForm.synonyms.featureName', { + defaultMessage: 'Search with Synonyms API', + }), + }, +}; + +const EducationPanel: React.FC<{ featureContent: string }> = ({ featureContent }) => { + const feature = getFeature(featureContent); + const { setFeaturesOther } = useActions(WorkplaceSearchGateLogic); + if (feature) { + return ( + + + + + + + + + +
    + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.educationalPanel.title', + { + defaultMessage: 'Elasticsearch native equivalent', + } + )} +
    +
    +
    + + +

    + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.educationalPanel.subTitle', + { + defaultMessage: 'Based on your selection we recommend you', + } + )} +

    +
    +
    +
    +
    +
    + + + +

    {feature.description}

    + + {feature.actionLink !== undefined && feature.actionLabel !== undefined && ( + + + {feature.actionLabel} + + + )} + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.educationalPanel.learnMore', + { + defaultMessage: 'Learn More', + } + )} + + + + {feature.addOnLearnMoreLabel !== undefined && feature.addOnLearnMoreUrl !== undefined && ( + + + + {feature.addOnLearnMoreLabel} + + + )} + +
    +
    + ); + } else { + return ( + <> + + + { + setFeaturesOther(e.target.value); + }} + /> + + + ); + } +}; +export const WorkplaceSearchGate: React.FC = () => { + const options = [ + { + dropdownDisplay: ( + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.contentSource.title', + { + defaultMessage: 'Content sources', + } + )} + + +

    + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.contentSource.description', + { + defaultMessage: + 'Extract the content of synced source files to make them searchable', + } + )} +

    +
    +
    + ), + inputDisplay: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.contentSource.inputDisplay', + { + defaultMessage: 'Content Sources', + } + ), + value: featuresList.contentSources.id, + }, + { + dropdownDisplay: ( + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.contentExtraction.title', + { + defaultMessage: 'Content extraction', + } + )} + + +

    + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.contentExtraction.description', + { + defaultMessage: + 'Extract the content of synced source files to make them searchable', + } + )} +

    +
    +
    + ), + inputDisplay: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.contentExtraction.inputDisplay', + { + defaultMessage: 'Content extraction', + } + ), + value: featuresList.contentExtraction.id, + }, + { + dropdownDisplay: ( + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.docLevelPermissions.title', + { + defaultMessage: 'Document-level permissions', + } + )} + + +

    + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.docLevelPermissions.description', + { + defaultMessage: 'Control access to specific documents', + } + )} +

    +
    +
    + ), + inputDisplay: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.docLevelPermissions.inputDisplay', + { + defaultMessage: 'Document-level permissions', + } + ), + value: featuresList.documentLevelPermissions.id, + }, + { + dropdownDisplay: ( + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.searchApplication.title', + { + defaultMessage: 'An out-of-the-box search experience', + } + )} + + +

    + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.searchApplication.description', + { + defaultMessage: 'Easily build search-powered applications', + } + )} +

    +
    +
    + ), + inputDisplay: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.searchApplication.inputDisplay', + { + defaultMessage: 'An out-of-the-box search experience', + } + ), + value: featuresList.searchApplication.id, + }, + { + dropdownDisplay: ( + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.synonymns.title', + { + defaultMessage: 'Synonyms', + } + )} + + +

    + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.synonymns.description', + { + defaultMessage: 'Link different words or phrases with similar meanings', + } + )} +

    +
    +
    + ), + inputDisplay: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.synonymns.inputDisplay', + { + defaultMessage: 'Synonyms', + } + ), + value: featuresList.synonyms.id, + }, + { + dropdownDisplay: ( + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.analytics.title', + { + defaultMessage: 'Analytics', + } + )} + + +

    + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.analytics.description', + { + defaultMessage: "Record and review users' interactions with search results", + } + )} +

    +
    +
    + ), + inputDisplay: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.analytics.inputDisplay', + { + defaultMessage: 'Analytics', + } + ), + value: featuresList.analytics.id, + }, + { + dropdownDisplay: ( + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.other.title', + { + defaultMessage: 'Other', + } + )} + + +

    + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.other.description', + { + defaultMessage: 'Another feature not listed here', + } + )} +

    +
    +
    + ), + inputDisplay: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.gateForm.superSelect.other.inputDisplay', + { + defaultMessage: 'Other', + } + ), + value: 'other', + }, + ]; + + const { setFormSubmitted, setAdditionalFeedback, setParticipateInUXLabs, setFeature } = + useActions(WorkplaceSearchGateLogic); + + const { feature, participateInUXLabs } = useValues(WorkplaceSearchGateLogic); + + return ( + + + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.gateForm.features.Label', { + defaultMessage: 'What Workplace Search feature are you looking to use?', + })} + + + + setFeature(value)} + itemLayoutAlign="top" + hasDividers + fullWidth + /> + + {feature && } + + + + + + { + setAdditionalFeedback(e.target.value); + }} + /> + + + + + + + ), + privacyStatementLink: ( + + + + ), + termsOfService: ( + + + + ), + }} + /> + + + + + + + + + setParticipateInUXLabs(e.target.value)} + value={participateInUXLabs} + /> + + + + + + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.gateForm.submit', { + defaultMessage: 'Submit', + })} + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/workplace_search_gate_form.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/workplace_search_gate_form.tsx new file mode 100644 index 0000000000000..50e4be57c68ef --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/workplace_search_gate_form.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { ENTERPRISE_SEARCH_CONTENT_PLUGIN } from '../../../../../common/constants'; + +import { + EnterpriseSearchPageTemplateWrapper, + PageTemplateProps, + useEnterpriseSearchNav, +} from '../../../shared/layout'; +import { SendWorkplaceSearchTelemetry } from '../../../shared/telemetry'; + +import { WorkplaceSearchGate } from './workplace_search_gate'; + +export const WorkplaceSearchGatePage: React.FC = ({ isLoading }) => { + return ( + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.gateForm.viewBlog', { + defaultMessage: 'blog', + })} + + ), + }} + /> + ), + pageTitle: i18n.translate('xpack.enterpriseSearch.workplaceSearch.gateForm.title', { + defaultMessage: 'Before you begin...', + }), + }} + solutionNav={{ + items: useEnterpriseSearchNav(), + name: ENTERPRISE_SEARCH_CONTENT_PLUGIN.NAME, + }} + isLoading={isLoading} + > + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/workplace_search_gate_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/workplace_search_gate_logic.ts new file mode 100644 index 0000000000000..f2eb603421c25 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/workplace_search_gate_logic.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +interface WorkplaceSearchGateValues { + additionalFeedback?: string; + feature: string; + featuresOther?: string; + isFormSubmitted: boolean; + participateInUXLabs?: string; +} +interface WorkplaceSearchGateActions { + setAdditionalFeedback(additionalFeedback: string): { additionalFeedback: string }; + setFeature(feature: string): { feature: string }; + setFeaturesOther(featuresOther: string): { featuresOther: string }; + setFormSubmitted(): void; + setParticipateInUXLabs(participateInUXLabs: string): { participateInUXLabs: boolean }; +} +export const WorkplaceSearchGateLogic = kea< + MakeLogicType +>({ + actions: { + setAdditionalFeedback: (additionalFeedback) => ({ additionalFeedback }), + setFeature: (feature) => ({ feature }), + setFeaturesOther: (featuresOther) => ({ featuresOther }), + setFormSubmitted: () => null, + setParticipateInUXLabs: (participateInUXLabs) => ({ participateInUXLabs }), + }, + path: ['enterprise_search', 'workplace_search', 'gate_form'], + reducers: { + additionalFeedback: [ + '', + { + setAdditionalFeedback: (_, { additionalFeedback }) => additionalFeedback, + }, + ], + feature: [ + '', + { + setFeature: (_, { feature }) => feature, + }, + ], + featuresOther: [ + '', + { + setFeaturesOther: (_, { featuresOther }) => featuresOther, + }, + ], + isFormSubmitted: [ + false, + { + setFormSubmitted: () => true, + }, + ], + participateInUXLabs: [ + '', + { + setParticipateInUXLabs: (_, { participateInUXLabs }) => participateInUXLabs, + }, + ], + }, +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts index 6243955ca63ad..ae6f7b4607653 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -96,6 +96,7 @@ describe('callEnterpriseSearchConfigAPI', () => { organization: { name: 'ACME Donuts', default_org_name: 'My Organization', + kibanaUIsEnabled: false, }, account: { id: 'some-id-string', @@ -188,6 +189,7 @@ describe('callEnterpriseSearchConfigAPI', () => { organization: { name: undefined, defaultOrgName: undefined, + kibanaUIsEnabled: false, }, account: { id: undefined, diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts index 16aa1ab0100b9..90fccce31344b 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -158,6 +158,8 @@ export const callEnterpriseSearchConfigAPI = async ({ organization: { name: data?.current_user?.workplace_search?.organization?.name, defaultOrgName: data?.current_user?.workplace_search?.organization?.default_org_name, + kibanaUIsEnabled: + data?.current_user?.workplace_search?.organization?.kibana_uis_enabled || false, }, account: { id: data?.current_user?.workplace_search?.account?.id, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/components/post_install_google_cloud_shell_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/components/post_install_google_cloud_shell_modal.tsx index a9185d3efa743..ce43298d8a97d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/components/post_install_google_cloud_shell_modal.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/components/post_install_google_cloud_shell_modal.tsx @@ -30,6 +30,7 @@ import { } from '../../../../../hooks'; import { GoogleCloudShellGuide } from '../../../../../components'; import { ManualInstructions } from '../../../../../../../components/enrollment_instructions'; +import { getGcpIntegrationDetailsFromPackagePolicy } from '../../../../../../../services'; export const PostInstallGoogleCloudShellModal: React.FunctionComponent<{ onConfirm: () => void; @@ -46,6 +47,8 @@ export const PostInstallGoogleCloudShellModal: React.FunctionComponent<{ ); const { fleetServerHosts, fleetProxy } = useFleetServerHostsForPolicy(agentPolicy); const agentVersion = useAgentVersion(); + const { gcpProjectId, gcpOrganizationId, gcpAccountType } = + getGcpIntegrationDetailsFromPackagePolicy(packagePolicy); const { cloudShellUrl, error, isError, isLoading } = useCreateCloudShellUrl({ enrollmentAPIKey: apyKeysData?.data?.items[0]?.api_key, @@ -61,6 +64,9 @@ export const PostInstallGoogleCloudShellModal: React.FunctionComponent<{ fleetServerHosts, fleetProxy, agentVersion, + gcpProjectId, + gcpOrganizationId, + gcpAccountType, }); return ( @@ -75,7 +81,10 @@ export const PostInstallGoogleCloudShellModal: React.FunctionComponent<{ - + {error && isError && ( <> diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/google_cloud_shell_instructions.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/google_cloud_shell_instructions.tsx index 89b6f67fe0dcf..a7090370680e4 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/google_cloud_shell_instructions.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/google_cloud_shell_instructions.tsx @@ -14,15 +14,17 @@ import { GoogleCloudShellGuide } from '../google_cloud_shell_guide'; interface Props { cloudShellUrl: string; cloudShellCommand: string; + projectId?: string; } export const GoogleCloudShellInstructions: React.FunctionComponent = ({ cloudShellUrl, cloudShellCommand, + projectId, }) => { return ( <> - + = ({ const agentVersion = useAgentVersion(); + const { gcpProjectId, gcpOrganizationId, gcpAccountType } = + getGcpIntegrationDetailsFromAgentPolicy(selectedPolicy); + const fleetServerHost = fleetServerHosts?.[0]; const installManagedCommands = ManualInstructions({ @@ -228,6 +235,9 @@ export const ManagedSteps: React.FunctionComponent = ({ fleetServerHosts, fleetProxy, agentVersion: agentVersion || '', + gcpProjectId, + gcpOrganizationId, + gcpAccountType, }); const instructionsSteps = useMemo(() => { @@ -273,6 +283,7 @@ export const ManagedSteps: React.FunctionComponent = ({ selectedApiKeyId, cloudShellUrl: cloudSecurityIntegration.cloudShellUrl, cloudShellCommand: installManagedCommands.googleCloudShell, + projectId: gcpProjectId, }) ); } else if (cloudSecurityIntegration?.isAzureArmTemplate) { @@ -343,6 +354,7 @@ export const ManagedSteps: React.FunctionComponent = ({ enrolledAgentIds, agentDataConfirmed, installedPackagePolicy, + gcpProjectId, ]); if (!agentVersion) { diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/install_google_cloud_shell_managed_agent_step.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/install_google_cloud_shell_managed_agent_step.tsx index ff367be9125fd..4e5b15c626735 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/install_google_cloud_shell_managed_agent_step.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/install_google_cloud_shell_managed_agent_step.tsx @@ -21,12 +21,14 @@ export const InstallGoogleCloudShellManagedAgentStep = ({ isComplete, cloudShellUrl, cloudShellCommand, + projectId, }: { selectedApiKeyId?: string; apiKeyData?: GetOneEnrollmentAPIKeyResponse | null; isComplete?: boolean; cloudShellUrl?: string | undefined; cloudShellCommand?: string; + projectId?: string; }): EuiContainedStepProps => { const nonCompleteStatus = selectedApiKeyId ? undefined : 'disabled'; const status = isComplete ? 'complete' : nonCompleteStatus; @@ -41,6 +43,7 @@ export const InstallGoogleCloudShellManagedAgentStep = ({ ) : ( diff --git a/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx index b7a4fed713cad..21c8ec6172cc7 100644 --- a/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx @@ -28,11 +28,17 @@ export const ManualInstructions = ({ fleetServerHosts, fleetProxy, agentVersion: agentVersion, + gcpProjectId = '', + gcpOrganizationId = '', + gcpAccountType, }: { apiKey: string; fleetServerHosts: string[]; fleetProxy?: FleetProxy; agentVersion: string; + gcpProjectId?: string; + gcpOrganizationId?: string; + gcpAccountType?: string; }) => { const enrollArgs = getfleetServerHostsEnrollArgs(apiKey, fleetServerHosts, fleetProxy); const fleetServerUrl = enrollArgs?.split('--url=')?.pop()?.split('--enrollment')[0]; @@ -64,7 +70,9 @@ sudo elastic-agent enroll ${enrollArgs} \nsudo systemctl enable elastic-agent \n sudo rpm -vi elastic-agent-${agentVersion}-x86_64.rpm sudo elastic-agent enroll ${enrollArgs} \nsudo systemctl enable elastic-agent \nsudo systemctl start elastic-agent`; - const googleCloudShellCommand = `gcloud config set project && \nFLEET_URL=${fleetServerUrl} ENROLLMENT_TOKEN=${enrollmentToken} STACK_VERSION=${agentVersion} ./deploy.sh`; + const googleCloudShellCommand = `gcloud config set project ${gcpProjectId} && ${ + gcpAccountType === 'organization-account' ? `\nORG_ID=${gcpOrganizationId}` : `` + } \nFLEET_URL=${fleetServerUrl} ENROLLMENT_TOKEN=${enrollmentToken} \nSTACK_VERSION=${agentVersion} ./deploy.sh`; return { linux: linuxCommand, diff --git a/x-pack/plugins/fleet/public/components/google_cloud_shell_guide.tsx b/x-pack/plugins/fleet/public/components/google_cloud_shell_guide.tsx index d494fc1075f41..1d5c804ef157a 100644 --- a/x-pack/plugins/fleet/public/components/google_cloud_shell_guide.tsx +++ b/x-pack/plugins/fleet/public/components/google_cloud_shell_guide.tsx @@ -23,7 +23,7 @@ const Link = ({ children, url }: { children: React.ReactNode; url: string }) => ); -export const GoogleCloudShellGuide = (props: { commandText: string }) => { +export const GoogleCloudShellGuide = (props: { commandText: string; hasProjectId?: boolean }) => { return ( <> @@ -48,10 +48,17 @@ export const GoogleCloudShellGuide = (props: { commandText: string }) => {
    1. <> - + {props?.hasProjectId ? ( + + ) : ( + + )} {props.commandText} diff --git a/x-pack/plugins/fleet/public/services/get_gcp_integration_details_from_agent_policy.test.ts b/x-pack/plugins/fleet/public/services/get_gcp_integration_details_from_agent_policy.test.ts new file mode 100644 index 0000000000000..e53e2dc36df07 --- /dev/null +++ b/x-pack/plugins/fleet/public/services/get_gcp_integration_details_from_agent_policy.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getGcpIntegrationDetailsFromAgentPolicy } from './get_gcp_integration_details_from_agent_policy'; + +const undefinedAllValue = { + gcpAccountType: undefined, + gcpOrganizationId: undefined, + gcpProjectId: undefined, +}; + +describe('getGcpIntegrationDetailsFromAgentPolicy', () => { + test('returns undefined when agentPolicy is undefined', () => { + const result = getGcpIntegrationDetailsFromAgentPolicy(undefined); + expect(result).toEqual(undefinedAllValue); + }); + + test('returns undefined when agentPolicy is defined but inputs are empty', () => { + const selectedPolicy = { inputs: [] }; + // @ts-expect-error + const result = getGcpIntegrationDetailsFromAgentPolicy(selectedPolicy); + expect(result).toEqual(undefinedAllValue); + }); + + it('should return undefined when no input has enabled and gcp integration details', () => { + const selectedPolicy = { + package_policies: [ + { + inputs: [ + { enabled: false, streams: [{}] }, + { enabled: true, streams: [{ vars: { other_property: 'false' } }] }, + { enabled: true, streams: [{ other_property: 'False' }] }, + ], + }, + { + inputs: [ + { enabled: false, streams: [{}] }, + { enabled: false, streams: [{}] }, + ], + }, + ], + }; + // @ts-expect-error + const result = getGcpIntegrationDetailsFromAgentPolicy(selectedPolicy); + expect(result).toEqual(undefinedAllValue); + }); + + it('should return the first gcp integration details when available', () => { + const selectedPolicy = { + package_policies: [ + { + inputs: [ + { enabled: false, streams: [{}] }, + { enabled: true, streams: [{ vars: { other_property: 'false' } }] }, + { enabled: true, streams: [{ other_property: 'False' }] }, + ], + }, + { + inputs: [ + { enabled: false, streams: [{}] }, + { + enabled: true, + streams: [ + { + vars: { + 'gcp.account_type': { value: 'account_type_test_1' }, + 'gcp.project_id': { value: 'project_id_1' }, + 'gcp.organization_id': { value: 'organization_id_1' }, + }, + }, + ], + }, + { + enabled: true, + streams: [ + { + vars: { + 'gcp.account_type': { value: 'account_type_test_2' }, + 'gcp.project_id': { value: 'project_id_2' }, + 'gcp.organization_id': { value: 'organization_id_2' }, + }, + }, + ], + }, + ], + }, + ], + }; + // @ts-expect-error + const result = getGcpIntegrationDetailsFromAgentPolicy(selectedPolicy); + expect(result).toEqual({ + gcpAccountType: 'account_type_test_1', + gcpOrganizationId: 'organization_id_1', + gcpProjectId: 'project_id_1', + }); + }); + // Add more test cases as needed +}); diff --git a/x-pack/plugins/fleet/public/services/get_gcp_integration_details_from_agent_policy.tsx b/x-pack/plugins/fleet/public/services/get_gcp_integration_details_from_agent_policy.tsx new file mode 100644 index 0000000000000..a1112683b4f1a --- /dev/null +++ b/x-pack/plugins/fleet/public/services/get_gcp_integration_details_from_agent_policy.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AgentPolicy } from '../types'; + +/** + * Get the project id, organization id and account type of gcp integration from an agent policy + */ +export const getGcpIntegrationDetailsFromAgentPolicy = (selectedPolicy?: AgentPolicy) => { + let gcpProjectId = selectedPolicy?.package_policies?.reduce((acc, packagePolicy) => { + const findGcpProjectId = packagePolicy.inputs?.reduce((accInput, input) => { + if (accInput !== '') { + return accInput; + } + if (input?.enabled && input?.streams[0]?.vars?.['gcp.project_id']?.value) { + return input?.streams[0]?.vars?.['gcp.project_id']?.value; + } + return accInput; + }, ''); + if (findGcpProjectId) { + return findGcpProjectId; + } + return acc; + }, ''); + + let gcpOrganizationId = selectedPolicy?.package_policies?.reduce((acc, packagePolicy) => { + const findGcpProjectId = packagePolicy.inputs?.reduce((accInput, input) => { + if (accInput !== '') { + return accInput; + } + if (input?.enabled && input?.streams[0]?.vars?.['gcp.organization_id']?.value) { + return input?.streams[0]?.vars?.['gcp.organization_id']?.value; + } + return accInput; + }, ''); + if (findGcpProjectId) { + return findGcpProjectId; + } + return acc; + }, ''); + + let gcpAccountType = selectedPolicy?.package_policies?.reduce((acc, packagePolicy) => { + const findGcpProjectId = packagePolicy.inputs?.reduce((accInput, input) => { + if (accInput !== '') { + return accInput; + } + if (input?.enabled && input?.streams[0]?.vars?.['gcp.account_type']?.value) { + return input?.streams[0]?.vars?.['gcp.account_type']?.value; + } + return accInput; + }, ''); + if (findGcpProjectId) { + return findGcpProjectId; + } + return acc; + }, ''); + + gcpProjectId = gcpProjectId !== '' ? gcpProjectId : undefined; + gcpOrganizationId = gcpOrganizationId !== '' ? gcpOrganizationId : undefined; + gcpAccountType = gcpAccountType !== '' ? gcpAccountType : undefined; + + return { + gcpProjectId, + gcpOrganizationId, + gcpAccountType, + }; +}; diff --git a/x-pack/plugins/fleet/public/services/get_gcp_integration_details_from_package_policy.test.ts b/x-pack/plugins/fleet/public/services/get_gcp_integration_details_from_package_policy.test.ts new file mode 100644 index 0000000000000..44da2fb65383a --- /dev/null +++ b/x-pack/plugins/fleet/public/services/get_gcp_integration_details_from_package_policy.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getGcpIntegrationDetailsFromPackagePolicy } from './get_gcp_integration_details_from_package_policy'; + +const undefinedAllValue = { + gcpAccountType: undefined, + gcpOrganizationId: undefined, + gcpProjectId: undefined, +}; + +describe('getGcpIntegrationDetailsFromPackagePolicy', () => { + test('returns undefined when packagePolicy is undefined', () => { + const result = getGcpIntegrationDetailsFromPackagePolicy(undefined); + expect(result).toEqual(undefinedAllValue); + }); + + test('returns undefined when packagePolicy is defined but inputs are empty', () => { + const packagePolicy = { inputs: [] }; + // @ts-expect-error + const result = getGcpIntegrationDetailsFromPackagePolicy(packagePolicy); + expect(result).toEqual(undefinedAllValue); + }); + + it('should return undefined when no input has enabled and gcp integration details', () => { + const packagePolicy = { + inputs: [ + { enabled: false, streams: [{}] }, + { enabled: true, streams: [{ vars: { other_property: 'false' } }] }, + { enabled: true, streams: [{ other_property: 'False' }] }, + ], + }; + // @ts-expect-error + const result = getGcpIntegrationDetailsFromPackagePolicy(packagePolicy); + expect(result).toEqual(undefinedAllValue); + }); + + it('should return the first gcp integration details when available', () => { + const packagePolicy = { + inputs: [ + { enabled: false, streams: [{}] }, + { + enabled: true, + streams: [ + { + vars: { + 'gcp.account_type': { value: 'account_type_test_1' }, + 'gcp.project_id': { value: 'project_id_1' }, + 'gcp.organization_id': { value: 'organization_id_1' }, + }, + }, + ], + }, + { + enabled: true, + streams: [ + { + vars: { + 'gcp.account_type': { value: 'account_type_test_2' }, + 'gcp.project_id': { value: 'project_id_2' }, + 'gcp.organization_id': { value: 'organization_id_2' }, + }, + }, + ], + }, + ], + }; + // @ts-expect-error + const result = getGcpIntegrationDetailsFromPackagePolicy(packagePolicy); + expect(result).toEqual({ + gcpAccountType: 'account_type_test_1', + gcpOrganizationId: 'organization_id_1', + gcpProjectId: 'project_id_1', + }); + }); + // Add more test cases as needed +}); diff --git a/x-pack/plugins/fleet/public/services/get_gcp_integration_details_from_package_policy.tsx b/x-pack/plugins/fleet/public/services/get_gcp_integration_details_from_package_policy.tsx new file mode 100644 index 0000000000000..ae82352d51e0a --- /dev/null +++ b/x-pack/plugins/fleet/public/services/get_gcp_integration_details_from_package_policy.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PackagePolicy } from '../types'; + +/** + * Get the project id, organization id and account type of gcp integration from a package policy + */ +export const getGcpIntegrationDetailsFromPackagePolicy = (packagePolicy?: PackagePolicy) => { + let gcpProjectId = packagePolicy?.inputs?.reduce((accInput, input) => { + if (accInput !== '') { + return accInput; + } + if (input?.enabled && input?.streams[0]?.vars?.['gcp.project_id']?.value) { + return input?.streams[0]?.vars?.['gcp.project_id']?.value; + } + return accInput; + }, ''); + + let gcpOrganizationId = packagePolicy?.inputs?.reduce((accInput, input) => { + if (accInput !== '') { + return accInput; + } + if (input?.enabled && input?.streams[0]?.vars?.['gcp.organization_id']?.value) { + return input?.streams[0]?.vars?.['gcp.organization_id']?.value; + } + return accInput; + }, ''); + + let gcpAccountType = packagePolicy?.inputs?.reduce((accInput, input) => { + if (accInput !== '') { + return accInput; + } + if (input?.enabled && input?.streams[0]?.vars?.['gcp.account_type']?.value) { + return input?.streams[0]?.vars?.['gcp.account_type']?.value; + } + return accInput; + }, ''); + + gcpProjectId = gcpProjectId !== '' ? gcpProjectId : undefined; + gcpOrganizationId = gcpOrganizationId !== '' ? gcpOrganizationId : undefined; + gcpAccountType = gcpAccountType !== '' ? gcpAccountType : undefined; + + return { + gcpProjectId, + gcpOrganizationId, + gcpAccountType, + }; +}; diff --git a/x-pack/plugins/fleet/public/services/index.ts b/x-pack/plugins/fleet/public/services/index.ts index 64009e4a11061..71f5fde90d93a 100644 --- a/x-pack/plugins/fleet/public/services/index.ts +++ b/x-pack/plugins/fleet/public/services/index.ts @@ -53,3 +53,5 @@ export { getTemplateUrlFromAgentPolicy } from './get_template_url_from_agent_pol export { getTemplateUrlFromPackageInfo } from './get_template_url_from_package_info'; export { getCloudShellUrlFromPackagePolicy } from './get_cloud_shell_url_from_package_policy'; export { getCloudShellUrlFromAgentPolicy } from './get_cloud_shell_url_from_agent_policy'; +export { getGcpIntegrationDetailsFromPackagePolicy } from './get_gcp_integration_details_from_package_policy'; +export { getGcpIntegrationDetailsFromAgentPolicy } from './get_gcp_integration_details_from_agent_policy'; diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index b7cf55510285f..b21af06c38349 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -36,6 +36,11 @@ import { migrateSyntheticsPackagePolicyToV8100 } from './migrations/synthetics/t import { migratePackagePolicyEvictionsFromV8100 } from './migrations/security_solution/to_v8_10_0'; +import { + migratePackagePolicyEvictionsFromV81102, + migratePackagePolicyToV81102, +} from './migrations/security_solution/to_v8_11_0_2'; + import { migrateAgentPolicyToV7100, migratePackagePolicyToV7100, @@ -335,6 +340,17 @@ const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({ forwardCompatibility: migratePackagePolicyEvictionsFromV8110, }, }, + '3': { + changes: [ + { + type: 'data_backfill', + backfillFn: migratePackagePolicyToV81102, + }, + ], + schemas: { + forwardCompatibility: migratePackagePolicyEvictionsFromV81102, + }, + }, }, migrations: { '7.10.0': migratePackagePolicyToV7100, diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_11_0_2.test.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_11_0_2.test.ts new file mode 100644 index 0000000000000..c6ee0ad133fcc --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_11_0_2.test.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { SavedObjectUnsanitizedDoc } from '@kbn/core/server'; + +import type { SavedObjectModelTransformationContext } from '@kbn/core-saved-objects-server'; + +import type { PackagePolicy } from '../../../../common'; + +import { migratePackagePolicyToV81102 as migration } from './to_v8_11_0_2'; +import { migratePackagePolicyEvictionsFromV81102 as eviction } from './to_v8_11_0_2'; + +describe('8.11.0-2 Endpoint Package Policy migration', () => { + const policyDoc = ({ meta = {} }) => { + return { + id: 'mock-saved-object-id', + attributes: { + name: 'Some Policy Name', + package: { + name: 'endpoint', + title: '', + version: '', + }, + id: 'endpoint', + policy_id: '', + enabled: true, + namespace: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + policy: { + value: { + meta: { + license: '', + cloud: false, + cluster_uuid: 'qwe', + cluster_name: 'clusterName', + ...meta, + }, + windows: {}, + mac: {}, + linux: {}, + }, + }, + }, + }, + ], + }, + type: ' nested', + }; + }; + + it('adds a new field `license_uuid` that takes the value of `license_uid` if it exists', () => { + const initialDoc = policyDoc({ meta: { license_uid: 'existing_uuid' } }); + + const migratedDoc = policyDoc({ + meta: { license_uid: 'existing_uuid', license_uuid: 'existing_uuid' }, + }); + + expect(migration(initialDoc, {} as SavedObjectModelTransformationContext)).toEqual({ + attributes: { + inputs: migratedDoc.attributes.inputs, + }, + }); + }); + + it('adds a new field `license_uuid` that takes an empty value if `existing_uid` does not exist', () => { + const initialDoc = policyDoc({}); + + const migratedDoc = policyDoc({ + meta: { license_uuid: '' }, + }); + + expect(migration(initialDoc, {} as SavedObjectModelTransformationContext)).toEqual({ + attributes: { + inputs: migratedDoc.attributes.inputs, + }, + }); + }); + + it('removes `license_uuid` for backwards compatibility', () => { + const initialDoc = policyDoc({ + meta: { license_uuid: 'existing_uuid' }, + }); + + const migratedDoc = policyDoc({}); + + expect(eviction(initialDoc.attributes)).toEqual(migratedDoc.attributes); + }); + + it('does not modify non-endpoint package policies', () => { + const doc: SavedObjectUnsanitizedDoc = { + id: 'mock-saved-object-id', + attributes: { + name: 'Some Policy Name', + package: { + name: 'notEndpoint', + title: '', + version: '', + }, + id: 'notEndpoint', + policy_id: '', + enabled: true, + namespace: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'notEndpoint', + enabled: true, + streams: [], + config: {}, + }, + ], + }, + type: ' nested', + }; + + expect( + migration( + doc, + {} as SavedObjectModelTransformationContext + ) as SavedObjectUnsanitizedDoc + ).toEqual({ + attributes: { + name: 'Some Policy Name', + package: { + name: 'notEndpoint', + title: '', + version: '', + }, + id: 'notEndpoint', + policy_id: '', + enabled: true, + namespace: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'notEndpoint', + enabled: true, + streams: [], + config: {}, + }, + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_11_0_2.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_11_0_2.ts new file mode 100644 index 0000000000000..2d858f3a7d222 --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_11_0_2.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectUnsanitizedDoc } from '@kbn/core/server'; + +import type { SavedObjectModelDataBackfillFn } from '@kbn/core-saved-objects-server'; + +import { omit } from 'lodash'; + +import type { SavedObjectModelVersionForwardCompatibilityFn } from '@kbn/core-saved-objects-server'; + +import type { PackagePolicy } from '../../../../common'; + +export const migratePackagePolicyToV81102: SavedObjectModelDataBackfillFn< + PackagePolicy, + PackagePolicy +> = (packagePolicyDoc) => { + if (packagePolicyDoc.attributes.package?.name !== 'endpoint') { + return { attributes: packagePolicyDoc.attributes }; + } + + const updatedPackagePolicyDoc: SavedObjectUnsanitizedDoc = packagePolicyDoc; + + const input = updatedPackagePolicyDoc.attributes.inputs[0]; + + if (input && input.config) { + const policy = input.config.policy.value; + + const newMetaValues = { + license_uuid: policy?.meta?.license_uid ? policy.meta.license_uid : '', + }; + + policy.meta = policy?.meta ? { ...policy.meta, ...newMetaValues } : newMetaValues; + } + + return { + attributes: { + inputs: updatedPackagePolicyDoc.attributes.inputs, + }, + }; +}; + +export const migratePackagePolicyEvictionsFromV81102: SavedObjectModelVersionForwardCompatibilityFn = + (unknownAttributes) => { + const attributes = unknownAttributes as PackagePolicy; + if (attributes.package?.name !== 'endpoint') { + return attributes; + } + + const updatedAttributes = attributes; + + const input = updatedAttributes.inputs[0]; + + if (input && input.config) { + const policy = input.config.policy.value; + + policy.meta = omit(policy.meta, ['license_uuid']); + } + + return updatedAttributes; + }; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/tabs.ts b/x-pack/plugins/infra/public/common/asset_details_config/asset_details_tabs.tsx similarity index 90% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/tabs.ts rename to x-pack/plugins/infra/public/common/asset_details_config/asset_details_tabs.tsx index 6ce0eeddbcb00..09e5cf2684075 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/tabs.ts +++ b/x-pack/plugins/infra/public/common/asset_details_config/asset_details_tabs.tsx @@ -6,9 +6,9 @@ */ import { i18n } from '@kbn/i18n'; -import { ContentTabIds, type Tab } from '../../../../../components/asset_details/types'; +import { ContentTabIds, type Tab } from '../../components/asset_details/types'; -export const orderedFlyoutTabs: Tab[] = [ +export const commonFlyoutTabs: Tab[] = [ { id: ContentTabIds.OVERVIEW, name: i18n.translate('xpack.infra.nodeDetails.tabs.overview.title', { diff --git a/x-pack/plugins/infra/public/components/asset_details/hooks/use_process_list.ts b/x-pack/plugins/infra/public/components/asset_details/hooks/use_process_list.ts new file mode 100644 index 0000000000000..97d567bb7066c --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/hooks/use_process_list.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import createContainter from 'constate'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { useEffect } from 'react'; +import { ProcessListAPIResponse, ProcessListAPIResponseRT } from '../../../../common/http_api'; +import { throwErrors, createPlainError } from '../../../../common/runtime_types'; +import { useHTTPRequest } from '../../../hooks/use_http_request'; +import { useSourceContext } from '../../../containers/metrics_source'; + +export interface SortBy { + name: string; + isAscending: boolean; +} + +export function useProcessList( + hostTerm: Record, + to: number, + sortBy: SortBy, + searchFilter: object +) { + const { createDerivedIndexPattern } = useSourceContext(); + const indexPattern = createDerivedIndexPattern().title; + + const decodeResponse = (response: any) => { + return pipe( + ProcessListAPIResponseRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); + }; + + const parsedSortBy = + sortBy.name === 'runtimeLength' + ? { + ...sortBy, + name: 'startTime', + } + : sortBy; + + const { error, loading, response, makeRequest } = useHTTPRequest( + '/api/metrics/process_list', + 'POST', + JSON.stringify({ + hostTerm, + indexPattern, + to, + sortBy: parsedSortBy, + searchFilter, + }), + decodeResponse + ); + + useEffect(() => { + makeRequest(); + }, [makeRequest]); + + return { + error: (error && error.message) || null, + loading, + response, + makeRequest, + }; +} + +function useProcessListParams(props: { hostTerm: Record; to: number }) { + const { hostTerm, to } = props; + const { createDerivedIndexPattern } = useSourceContext(); + const indexPattern = createDerivedIndexPattern().title; + return { hostTerm, indexPattern, to }; +} +const ProcessListContext = createContainter(useProcessListParams); +export const [ProcessListContextProvider, useProcessListContext] = ProcessListContext; diff --git a/x-pack/plugins/infra/public/components/asset_details/hooks/use_process_list_row_chart.ts b/x-pack/plugins/infra/public/components/asset_details/hooks/use_process_list_row_chart.ts new file mode 100644 index 0000000000000..f964167b8eb18 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/hooks/use_process_list_row_chart.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { useEffect, useState } from 'react'; +import { + ProcessListAPIChartResponse, + ProcessListAPIChartResponseRT, +} from '../../../../common/http_api'; +import { throwErrors, createPlainError } from '../../../../common/runtime_types'; +import { useHTTPRequest } from '../../../hooks/use_http_request'; +import { useProcessListContext } from './use_process_list'; + +export function useProcessListRowChart(command: string) { + const [inErrorState, setInErrorState] = useState(false); + const decodeResponse = (response: any) => { + return pipe( + ProcessListAPIChartResponseRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); + }; + const { hostTerm, indexPattern, to } = useProcessListContext(); + + const { error, loading, response, makeRequest } = useHTTPRequest( + '/api/metrics/process_list/chart', + 'POST', + JSON.stringify({ + hostTerm, + indexPattern, + to, + command, + }), + decodeResponse + ); + + useEffect(() => setInErrorState(true), [error]); + useEffect(() => setInErrorState(false), [loading]); + + useEffect(() => { + makeRequest(); + }, [makeRequest]); + + return { + error: inErrorState, + loading, + response, + makeRequest, + }; +} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs/processes/process_row.tsx similarity index 100% rename from x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row.tsx rename to x-pack/plugins/infra/public/components/asset_details/tabs/processes/process_row.tsx diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs/processes/process_row_charts.tsx similarity index 85% rename from x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx rename to x-pack/plugins/infra/public/components/asset_details/tabs/processes/process_row_charts.tsx index b19a859f196ea..e59aacf03c9d5 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/tabs/processes/process_row_charts.tsx @@ -19,15 +19,15 @@ import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { first, last } from 'lodash'; import moment from 'moment'; import React, { useMemo } from 'react'; -import { useTimelineChartTheme } from '../../../../../../../utils/use_timeline_chart_theme'; -import { Color } from '../../../../../../../../common/color_palette'; -import { createFormatter } from '../../../../../../../../common/formatters'; -import { MetricsExplorerAggregation } from '../../../../../../../../common/http_api'; -import { calculateDomain } from '../../../../../metrics_explorer/components/helpers/calculate_domain'; -import { MetricExplorerSeriesChart } from '../../../../../metrics_explorer/components/series_chart'; -import { MetricsExplorerChartType } from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options'; -import { useProcessListRowChart } from '../../../../hooks/use_process_list_row_chart'; +import { calculateDomain } from '../../../../pages/metrics/metrics_explorer/components/helpers/calculate_domain'; +import { useProcessListRowChart } from '../../hooks/use_process_list_row_chart'; +import { useTimelineChartTheme } from '../../../../utils/use_timeline_chart_theme'; +import { MetricExplorerSeriesChart } from '../../../../pages/metrics/metrics_explorer/components/series_chart'; +import { Color } from '../../../../../common/color_palette'; +import { createFormatter } from '../../../../../common/formatters'; +import { MetricsExplorerAggregation } from '../../../../../common/http_api'; import { Process } from './types'; +import { MetricsExplorerChartType } from '../../../../../common/metrics_explorer_views/types'; interface Props { command: string; diff --git a/x-pack/plugins/infra/public/components/asset_details/tabs/processes/processes_table.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs/processes/processes_table.tsx index 95bfce420e784..cf2f97d37b33c 100644 --- a/x-pack/plugins/infra/public/components/asset_details/tabs/processes/processes_table.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/tabs/processes/processes_table.tsx @@ -30,9 +30,9 @@ import { css } from '@emotion/react'; import { EuiTableRow } from '@elastic/eui'; import { EuiIcon } from '@elastic/eui'; import { FORMATTERS } from '../../../../../common/formatters'; -import type { SortBy } from '../../../../pages/metrics/inventory_view/hooks/use_process_list'; +import type { SortBy } from '../../hooks/use_process_list'; import type { Process } from './types'; -import { ProcessRow } from '../../../../pages/metrics/inventory_view/components/node_details/tabs/processes/process_row'; +import { ProcessRow } from './process_row'; import { StateBadge } from './state_badge'; import { STATE_ORDER } from './states'; import type { ProcessListAPIResponse } from '../../../../../common/http_api'; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout_wrapper.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout_wrapper.tsx index 1fab43b5cd144..4015e536a786e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout_wrapper.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout_wrapper.tsx @@ -10,7 +10,7 @@ import { useSourceContext } from '../../../../../containers/metrics_source'; import { useUnifiedSearchContext } from '../../hooks/use_unified_search'; import type { HostNodeRow } from '../../hooks/use_hosts_table'; import { AssetDetails } from '../../../../../components/asset_details/asset_details'; -import { orderedFlyoutTabs } from './tabs'; +import { commonFlyoutTabs } from '../../../../../common/asset_details_config/asset_details_tabs'; export interface Props { node: HostNodeRow; @@ -31,7 +31,7 @@ export const FlyoutWrapper = ({ node: { name }, closeFlyout }: Props) => { showActionsColumn: true, }, }} - tabs={orderedFlyoutTabs} + tabs={commonFlyoutTabs} links={['apmServices', 'nodeDetails']} renderMode={{ mode: 'flyout', diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx deleted file mode 100644 index 7614b46014378..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx +++ /dev/null @@ -1,233 +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 { EuiPortal, EuiTabs, EuiTab, EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useMemo, useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import { EuiOutsideClickDetector } from '@elastic/eui'; -import { EuiIcon, EuiButtonIcon } from '@elastic/eui'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { useLinkProps } from '@kbn/observability-shared-plugin/public'; -import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/lib'; -import { InventoryItemType } from '../../../../../../common/inventory_models/types'; -import { MetricsTab } from './tabs/metrics/metrics'; -import { LogsTab } from './tabs/logs'; -import { ProcessesTab } from './tabs/processes'; -import { PropertiesTab } from './tabs/properties'; -import { AnomaliesTab } from './tabs/anomalies/anomalies'; -import { OsqueryTab } from './tabs/osquery'; -import { OVERLAY_Y_START, OVERLAY_BOTTOM_MARGIN } from './tabs/shared'; -import { useNodeDetailsRedirect } from '../../../../link_to'; -import { findInventoryModel } from '../../../../../../common/inventory_models'; -import { navigateToUptime } from '../../lib/navigate_to_uptime'; -import { InfraClientCoreStart, InfraClientStartDeps } from '../../../../../types'; - -interface Props { - isOpen: boolean; - onClose(): void; - options: InfraWaffleMapOptions; - currentTime: number; - node: InfraWaffleMapNode; - nodeType: InventoryItemType; - openAlertFlyout(): void; -} -export const NodeContextPopover = ({ - isOpen, - node, - nodeType, - currentTime, - options, - onClose, - openAlertFlyout, -}: Props) => { - // eslint-disable-next-line react-hooks/exhaustive-deps - const tabConfigs = [MetricsTab, LogsTab, ProcessesTab, PropertiesTab, AnomaliesTab, OsqueryTab]; - const inventoryModel = findInventoryModel(nodeType); - const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000; - const { application, share } = useKibana().services; - const { getNodeDetailUrl } = useNodeDetailsRedirect(); - const uiCapabilities = application?.capabilities; - const canCreateAlerts = useMemo( - () => Boolean(uiCapabilities?.infrastructure?.save), - [uiCapabilities] - ); - - const tabs = useMemo(() => { - return tabConfigs.map((m) => { - const TabContent = m.content; - return { - ...m, - content: ( - - ), - }; - }); - }, [tabConfigs, node, nodeType, currentTime, onClose, options]); - - const [selectedTab, setSelectedTab] = useState(0); - - const nodeDetailMenuItemLinkProps = useLinkProps({ - ...getNodeDetailUrl({ - assetType: nodeType, - assetId: node.id, - search: { - from: nodeDetailFrom, - to: currentTime, - name: node.name, - }, - }), - }); - const apmField = nodeType === 'host' ? 'host.hostname' : inventoryModel.fields.id; - const apmTracesMenuItemLinkProps = useLinkProps({ - app: 'apm', - hash: 'traces', - search: { - kuery: `${apmField}:"${node.id}"`, - }, - }); - - if (!isOpen) { - return null; - } - - return ( - - - - - - - -

      {node.name}

      -
      -
      - - - {canCreateAlerts && ( - - - - - - )} - - - - - - - - - - -
      - - - {tabs.map((tab, i) => ( - setSelectedTab(i)} - > - {tab.name} - - ))} - - {' '} - - - navigateToUptime(share.url.locators, nodeType, node)}> - {' '} - - - -
      - {tabs[selectedTab].content} -
      -
      -
      - ); -}; - -const OverlayHeader = euiStyled.div` - padding-top: ${(props) => props.theme.eui.euiSizeM}; - padding-right: ${(props) => props.theme.eui.euiSizeM}; - padding-left: ${(props) => props.theme.eui.euiSizeM}; - background-color: ${(props) => props.theme.eui.euiPageBackgroundColor}; - box-shadow: inset 0 -1px ${(props) => props.theme.eui.euiBorderColor}; -`; - -const OverlayPanel = euiStyled(EuiPanel).attrs({ paddingSize: 'none' })` - display: flex; - flex-direction: column; - position: absolute; - right: 16px; - top: ${OVERLAY_Y_START}px; - width: 100%; - max-width: 720px; - z-index: 2; - max-height: calc(100vh - ${OVERLAY_Y_START + OVERLAY_BOTTOM_MARGIN}px); - overflow: hidden; - - @media (max-width: 752px) { - border-radius: 0px !important; - left: 0px; - right: 0px; - top: 97px; - bottom: 0; - max-height: calc(100vh - 97px); - max-width: 100%; - } -`; - -const OverlayTitle = euiStyled(EuiFlexItem)` - overflow: hidden; - & h4 { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/anomalies/anomalies.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/anomalies/anomalies.tsx deleted file mode 100644 index 40dad03f73366..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/anomalies/anomalies.tsx +++ /dev/null @@ -1,29 +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 { i18n } from '@kbn/i18n'; -import React from 'react'; -import { AnomaliesTable } from '../../../ml/anomaly_detection/anomalies_table/anomalies_table'; -import { TabContent, TabProps } from '../shared'; - -const TabComponent = (props: TabProps) => { - const { node, onClose } = props; - - return ( - - - - ); -}; - -export const AnomaliesTab = { - id: 'anomalies', - name: i18n.translate('xpack.infra.nodeDetails.tabs.anomalies', { - defaultMessage: 'Anomalies', - }), - content: TabComponent, -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx deleted file mode 100644 index ddbf4ae52788b..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useMemo, useState } from 'react'; -import useDebounce from 'react-use/lib/useDebounce'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import { EuiFieldSearch } from '@elastic/eui'; -import { EuiFlexGroup } from '@elastic/eui'; -import { EuiFlexItem } from '@elastic/eui'; -import { EuiButtonEmpty } from '@elastic/eui'; -import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; -import { LogStream } from '@kbn/logs-shared-plugin/public'; -import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana'; -import { TabContent, TabProps } from './shared'; -import { useWaffleOptionsContext } from '../../../hooks/use_waffle_options'; -import { findInventoryFields } from '../../../../../../../common/inventory_models'; - -const TabComponent = (props: TabProps) => { - const { services } = useKibanaContextForPlugin(); - const { locators } = services; - const [textQuery, setTextQuery] = useState(''); - const [textQueryDebounced, setTextQueryDebounced] = useState(''); - const endTimestamp = props.currentTime; - const startTimestamp = endTimestamp - 60 * 60 * 1000; // 60 minutes - const { nodeType } = useWaffleOptionsContext(); - const { node } = props; - - useDebounce(() => setTextQueryDebounced(textQuery), textQueryThrottleInterval, [textQuery]); - - const filter = useMemo(() => { - const query = [ - `${findInventoryFields(nodeType).id}: "${node.id}"`, - ...(textQueryDebounced !== '' ? [textQueryDebounced] : []), - ].join(' and '); - - return { - language: 'kuery', - query, - }; - }, [nodeType, node.id, textQueryDebounced]); - - const onQueryChange = useCallback((e: React.ChangeEvent) => { - setTextQuery(e.target.value); - }, []); - - const logsUrl = useMemo(() => { - return locators.nodeLogsLocator.getRedirectUrl({ - nodeType, - nodeId: node.id, - time: startTimestamp, - filter: textQueryDebounced, - }); - }, [locators.nodeLogsLocator, node.id, nodeType, startTimestamp, textQueryDebounced]); - - return ( - - - - - - - - - - - - - - - - ); -}; - -export const LogsTab = { - id: 'logs', - name: i18n.translate('xpack.infra.nodeDetails.tabs.logs', { - defaultMessage: 'Logs', - }), - content: TabComponent, -}; - -const textQueryThrottleInterval = 1000; // milliseconds diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx deleted file mode 100644 index 2c371691f4587..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx +++ /dev/null @@ -1,63 +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 { EuiText } from '@elastic/eui'; -import { EuiFlexItem } from '@elastic/eui'; -import React from 'react'; -import { EuiFlexGroup } from '@elastic/eui'; -import { EuiIcon } from '@elastic/eui'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { colorTransformer } from '../../../../../../../../common/color_palette'; -import { MetricsExplorerOptionsMetric } from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options'; - -interface Props { - title: string; - metrics: MetricsExplorerOptionsMetric[]; -} - -export const ChartHeader = ({ title, metrics }: Props) => { - return ( - - - -

      {title}

      -
      -
      - - - {metrics.map((chartMetric) => ( - - - - - - - {chartMetric.label} - - - - ))} - - -
      - ); -}; - -const HeaderItem = euiStyled(EuiFlexItem).attrs({ grow: 1 })` - overflow: hidden; -`; - -const H4 = euiStyled('h4')` - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx deleted file mode 100644 index e078f25d60437..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx +++ /dev/null @@ -1,103 +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 { - Axis, - Chart, - ChartSizeArray, - PointerUpdateListener, - Position, - Settings, - TickFormatter, - TooltipProps, - Tooltip, -} from '@elastic/charts'; -import moment from 'moment'; -import React from 'react'; -import { useTimelineChartTheme } from '../../../../../../../utils/use_timeline_chart_theme'; -import { MetricsExplorerSeries } from '../../../../../../../../common/http_api'; -import { MetricExplorerSeriesChart } from '../../../../../metrics_explorer/components/series_chart'; -import { - MetricsExplorerChartType, - MetricsExplorerOptionsMetric, -} from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options'; -import { ChartHeader } from './chart_header'; - -const CHART_SIZE: ChartSizeArray = ['100%', 160]; - -interface Props { - title: string; - style: MetricsExplorerChartType; - chartRef: React.Ref; - series: ChartSectionSeries[]; - tickFormatterForTime: TickFormatter; - tickFormatter: TickFormatter; - onPointerUpdate: PointerUpdateListener; - domain: { max: number; min: number }; - stack?: boolean; -} - -export interface ChartSectionSeries { - metric: MetricsExplorerOptionsMetric; - series: MetricsExplorerSeries; -} - -export const ChartSection = ({ - title, - style, - chartRef, - series, - tickFormatterForTime, - tickFormatter, - onPointerUpdate, - domain, - stack = false, -}: Props) => { - const chartTheme = useTimelineChartTheme(); - const metrics = series.map((chartSeries) => chartSeries.metric); - const tooltipProps: TooltipProps = { - headerFormatter: ({ value }) => moment(value).format('Y-MM-DD HH:mm:ss.SSS'), - }; - - return ( - <> - - - {series.map((chartSeries, index) => ( - - ))} - - - - - - - ); -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx deleted file mode 100644 index 6e8908bf2362a..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx +++ /dev/null @@ -1,460 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { Chart, niceTimeFormatter, PointerEvent } from '@elastic/charts'; -import { EuiLoadingChart, EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; -import { first, last } from 'lodash'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { TabContent, TabProps } from '../shared'; -import { useSnapshot } from '../../../../hooks/use_snaphot'; -import { useWaffleOptionsContext } from '../../../../hooks/use_waffle_options'; -import { useSourceContext } from '../../../../../../../containers/metrics_source'; -import { findInventoryFields } from '../../../../../../../../common/inventory_models'; -import { convertKueryToElasticSearchQuery } from '../../../../../../../utils/kuery'; -import { SnapshotMetricType } from '../../../../../../../../common/inventory_models/types'; -import { - MetricsExplorerChartType, - MetricsExplorerOptionsMetric, -} from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options'; -import { Color } from '../../../../../../../../common/color_palette'; -import { - MetricsExplorerAggregation, - MetricsExplorerSeries, -} from '../../../../../../../../common/http_api'; -import { createInventoryMetricFormatter } from '../../../../lib/create_inventory_metric_formatter'; -import { calculateDomain } from '../../../../../metrics_explorer/components/helpers/calculate_domain'; -import { ChartSection } from './chart_section'; -import { - SYSTEM_METRIC_NAME, - USER_METRIC_NAME, - INBOUND_METRIC_NAME, - OUTBOUND_METRIC_NAME, - USED_MEMORY_METRIC_NAME, - FREE_MEMORY_METRIC_NAME, - CPU_CHART_TITLE, - LOAD_CHART_TITLE, - MEMORY_CHART_TITLE, - NETWORK_CHART_TITLE, - LOG_RATE_METRIC_NAME, - LOG_RATE_CHART_TITLE, -} from './translations'; -import { TimeDropdown } from './time_dropdown'; -import { getCustomMetricLabel } from '../../../../../../../../common/formatters/get_custom_metric_label'; -import { createFormatterForMetric } from '../../../../../metrics_explorer/components/helpers/create_formatter_for_metric'; - -const ONE_HOUR = 60 * 60 * 1000; - -const TabComponent = (props: TabProps) => { - const cpuChartRef = useRef(null); - const networkChartRef = useRef(null); - const memoryChartRef = useRef(null); - const loadChartRef = useRef(null); - const logRateChartRef = useRef(null); - const customMetricRefs = useRef>({}); - const [time, setTime] = useState(ONE_HOUR); - const chartRefs = useMemo(() => { - const refs = [cpuChartRef, networkChartRef, memoryChartRef, loadChartRef, logRateChartRef]; - return [...refs, customMetricRefs]; - }, [ - cpuChartRef, - networkChartRef, - memoryChartRef, - loadChartRef, - logRateChartRef, - customMetricRefs, - ]); - const { sourceId, createDerivedIndexPattern } = useSourceContext(); - const { nodeType, accountId, region, customMetrics } = useWaffleOptionsContext(); - const { currentTime, node } = props; - const derivedIndexPattern = useMemo( - () => createDerivedIndexPattern(), - [createDerivedIndexPattern] - ); - let filter = `${findInventoryFields(nodeType).id}: "${node.id}"`; - - if (filter) { - filter = convertKueryToElasticSearchQuery(filter, derivedIndexPattern); - } - - const buildCustomMetric = useCallback( - (field: string, id: string, aggregation: string = 'avg') => ({ - type: 'custom' as SnapshotMetricType, - aggregation, - field, - id, - }), - [] - ); - - const updateTime = useCallback( - (e: React.ChangeEvent) => { - setTime(Number(e.currentTarget.value)); - }, - [setTime] - ); - - const timeRange = { - interval: '1m', - to: currentTime, - from: currentTime - time, - ignoreLookback: true, - }; - - const defaultMetrics: Array<{ type: SnapshotMetricType }> = [ - { type: 'rx' }, - { type: 'tx' }, - buildCustomMetric('system.cpu.user.pct', 'user'), - buildCustomMetric('system.cpu.system.pct', 'system'), - buildCustomMetric('system.load.1', 'load1m'), - buildCustomMetric('system.load.5', 'load5m'), - buildCustomMetric('system.load.15', 'load15m'), - buildCustomMetric('system.memory.actual.used.bytes', 'usedMemory'), - buildCustomMetric('system.memory.actual.free', 'freeMemory'), - buildCustomMetric('system.cpu.cores', 'cores', 'max'), - ]; - - const { nodes, reload } = useSnapshot({ - filterQuery: filter, - metrics: [...defaultMetrics, ...customMetrics], - groupBy: [], - nodeType, - sourceId, - currentTime, - accountId, - region, - sendRequestImmediately: false, - timerange: timeRange, - }); - - const { nodes: logRateNodes, reload: reloadLogRate } = useSnapshot({ - filterQuery: filter, - metrics: [{ type: 'logRate' }], - groupBy: [], - nodeType, - sourceId, - currentTime, - accountId, - region, - sendRequestImmediately: false, - timerange: timeRange, - }); - - const getDomain = useCallback( - (timeseries: MetricsExplorerSeries, ms: MetricsExplorerOptionsMetric[]) => { - const dataDomain = timeseries ? calculateDomain(timeseries, ms, false) : null; - return dataDomain - ? { - max: dataDomain.max * 1.1, // add 10% headroom. - min: dataDomain.min, - } - : { max: 0, min: 0 }; - }, - [] - ); - - const dateFormatter = useCallback((timeseries: MetricsExplorerSeries) => { - if (!timeseries) return () => ''; - const firstTimestamp = first(timeseries.rows)?.timestamp; - const lastTimestamp = last(timeseries.rows)?.timestamp; - - if (firstTimestamp == null || lastTimestamp == null) { - return (value: number) => `${value}`; - } - - return niceTimeFormatter([firstTimestamp, lastTimestamp]); - }, []); - - const networkFormatter = useMemo(() => createInventoryMetricFormatter({ type: 'rx' }), []); - const cpuFormatter = useMemo(() => createInventoryMetricFormatter({ type: 'cpu' }), []); - const memoryFormatter = useMemo( - () => createInventoryMetricFormatter({ type: 's3BucketSize' }), - [] - ); - const loadFormatter = useMemo(() => createInventoryMetricFormatter({ type: 'load' }), []); - const logRateFormatter = useMemo(() => createInventoryMetricFormatter({ type: 'logRate' }), []); - - const mergeTimeseries = useCallback((...series: MetricsExplorerSeries[]) => { - const base = series[0]; - const otherSeries = series.slice(1); - base.rows = base.rows.map((b, rowIdx) => { - const newRow = { ...b }; - otherSeries.forEach((o, idx) => { - newRow[`metric_${idx + 1}`] = o.rows[rowIdx].metric_0; - }); - return newRow; - }); - return base; - }, []); - - const buildChartMetricLabels = useCallback( - (labels: string[], aggregation: MetricsExplorerAggregation) => { - const baseMetric = { - color: Color.color0, - aggregation, - label: 'System', - }; - - return labels.map((label, idx) => { - return { ...baseMetric, color: Color[`color${idx}` as Color], label }; - }); - }, - [] - ); - - const pointerUpdate = useCallback( - (event: PointerEvent) => { - chartRefs.forEach((ref) => { - if (ref.current) { - if (ref.current instanceof Chart) { - ref.current.dispatchExternalPointerEvent(event); - } else { - const charts = Object.values(ref.current); - charts.forEach((c) => { - if (c) { - c.dispatchExternalPointerEvent(event); - } - }); - } - } - }); - }, - [chartRefs] - ); - - const getTimeseries = useCallback( - (metricName: string) => { - if (!nodes || !nodes.length) { - return null; - } - return nodes[0].metrics.find((m) => m.name === metricName)!.timeseries!; - }, - [nodes] - ); - - const getLogRateTimeseries = useCallback(() => { - if (!logRateNodes) { - return null; - } - if (logRateNodes.length === 0) { - return { rows: [], columns: [], id: '0' }; - } - return logRateNodes[0].metrics.find((m) => m.name === 'logRate')!.timeseries!; - }, [logRateNodes]); - - const systemMetricsTs = useMemo(() => getTimeseries('system'), [getTimeseries]); - const userMetricsTs = useMemo(() => getTimeseries('user'), [getTimeseries]); - const rxMetricsTs = useMemo(() => getTimeseries('rx'), [getTimeseries]); - const txMetricsTs = useMemo(() => getTimeseries('tx'), [getTimeseries]); - const load1mMetricsTs = useMemo(() => getTimeseries('load1m'), [getTimeseries]); - const load5mMetricsTs = useMemo(() => getTimeseries('load5m'), [getTimeseries]); - const load15mMetricsTs = useMemo(() => getTimeseries('load15m'), [getTimeseries]); - const usedMemoryMetricsTs = useMemo(() => getTimeseries('usedMemory'), [getTimeseries]); - const freeMemoryMetricsTs = useMemo(() => getTimeseries('freeMemory'), [getTimeseries]); - const coresMetricsTs = useMemo(() => getTimeseries('cores'), [getTimeseries]); - const logRateMetricsTs = useMemo(() => getLogRateTimeseries(), [getLogRateTimeseries]); - - useEffect(() => { - reload(); - reloadLogRate(); - }, [time, reload, reloadLogRate]); - - if ( - !systemMetricsTs || - !userMetricsTs || - !rxMetricsTs || - !txMetricsTs || - !load1mMetricsTs || - !load5mMetricsTs || - !load15mMetricsTs || - !usedMemoryMetricsTs || - !freeMemoryMetricsTs || - !logRateMetricsTs - ) { - return ; - } - - const cpuChartMetrics = buildChartMetricLabels([SYSTEM_METRIC_NAME, USER_METRIC_NAME], 'avg'); - const logRateChartMetrics = buildChartMetricLabels([LOG_RATE_METRIC_NAME], 'rate'); - const networkChartMetrics = buildChartMetricLabels( - [INBOUND_METRIC_NAME, OUTBOUND_METRIC_NAME], - 'rate' - ); - const loadChartMetrics = buildChartMetricLabels(['1m', '5m', '15m'], 'avg'); - const memoryChartMetrics = buildChartMetricLabels( - [USED_MEMORY_METRIC_NAME, FREE_MEMORY_METRIC_NAME], - 'rate' - ); - - systemMetricsTs.rows = systemMetricsTs.rows.slice().map((r, idx) => { - const metric = r.metric_0 as number | undefined; - const cores = coresMetricsTs!.rows[idx].metric_0 as number | undefined; - if (metric && cores) { - r.metric_0 = metric / cores; - } - return r; - }); - - userMetricsTs.rows = userMetricsTs.rows.slice().map((r, idx) => { - const metric = r.metric_0 as number | undefined; - const cores = coresMetricsTs!.rows[idx].metric_0 as number | undefined; - if (metric && cores) { - r.metric_0 = metric / cores; - } - return r; - }); - const cpuTimeseries = mergeTimeseries(systemMetricsTs, userMetricsTs); - const logRateTimeseries = mergeTimeseries(logRateMetricsTs); - const networkTimeseries = mergeTimeseries(rxMetricsTs, txMetricsTs); - const loadTimeseries = mergeTimeseries(load1mMetricsTs, load5mMetricsTs, load15mMetricsTs); - const memoryTimeseries = mergeTimeseries(usedMemoryMetricsTs, freeMemoryMetricsTs); - - const formatter = dateFormatter(rxMetricsTs); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - {customMetrics.map((c) => { - const metricTS = getTimeseries(c.id); - const chartMetrics = buildChartMetricLabels([c.field], c.aggregation); - if (!metricTS) return null; - return ( - - { - customMetricRefs.current[c.id] = r; - }} - series={[{ metric: chartMetrics[0], series: metricTS }]} - tickFormatterForTime={formatter} - tickFormatter={createFormatterForMetric(c)} - onPointerUpdate={pointerUpdate} - domain={getDomain(mergeTimeseries(metricTS), chartMetrics)} - stack={true} - /> - - ); - })} - - - ); -}; - -const ChartGridItem = euiStyled(EuiFlexItem)` - overflow: hidden -`; - -const LoadingPlaceholder = () => { - return ( -
      - -
      - ); -}; - -export const MetricsTab = { - id: 'metrics', - name: i18n.translate('xpack.infra.nodeDetails.tabs.metrics', { - defaultMessage: 'Metrics', - }), - content: TabComponent, -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/time_dropdown.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/time_dropdown.tsx deleted file mode 100644 index a7e9aef892e04..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/time_dropdown.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiSelect } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -interface Props { - value: number; - onChange(event: React.ChangeEvent): void; -} - -export const TimeDropdown = (props: Props) => ( - -); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/translations.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/translations.tsx deleted file mode 100644 index 940d97918b181..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/translations.tsx +++ /dev/null @@ -1,66 +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 { i18n } from '@kbn/i18n'; - -export const SYSTEM_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.system', { - defaultMessage: 'System', -}); - -export const USER_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.user', { - defaultMessage: 'User', -}); - -export const INBOUND_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.inbound', { - defaultMessage: 'Inbound', -}); - -export const OUTBOUND_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.outbound', { - defaultMessage: 'Outbound', -}); - -export const USED_MEMORY_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.used', { - defaultMessage: 'Used', -}); - -export const CACHED_MEMORY_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.cached', { - defaultMessage: 'Cached', -}); - -export const FREE_MEMORY_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.free', { - defaultMessage: 'Free', -}); - -export const NETWORK_CHART_TITLE = i18n.translate( - 'xpack.infra.nodeDetails.metrics.charts.networkTitle', - { - defaultMessage: 'Network', - } -); -export const MEMORY_CHART_TITLE = i18n.translate( - 'xpack.infra.nodeDetails.metrics.charts.memoryTitle', - { - defaultMessage: 'Memory', - } -); -export const CPU_CHART_TITLE = i18n.translate('xpack.infra.nodeDetails.metrics.fcharts.cpuTitle', { - defaultMessage: 'CPU', -}); -export const LOAD_CHART_TITLE = i18n.translate('xpack.infra.nodeDetails.metrics.charts.loadTitle', { - defaultMessage: 'Load', -}); - -export const LOG_RATE_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.logRate', { - defaultMessage: 'Log Rate', -}); - -export const LOG_RATE_CHART_TITLE = i18n.translate( - 'xpack.infra.nodeDetails.metrics.charts.logRateTitle', - { - defaultMessage: 'Log Rate', - } -); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/osquery/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/osquery/index.tsx deleted file mode 100644 index 7339043ffa98f..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/osquery/index.tsx +++ /dev/null @@ -1,63 +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 { EuiSkeletonText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; -import { useKibanaContextForPlugin } from '../../../../../../../hooks/use_kibana'; -import { TabContent, TabProps } from '../shared'; -import { useSourceContext } from '../../../../../../../containers/metrics_source'; -import { InventoryItemType } from '../../../../../../../../common/inventory_models/types'; -import { useMetadata } from '../../../../../../../components/asset_details/hooks/use_metadata'; -import { useWaffleTimeContext } from '../../../../hooks/use_waffle_time'; - -const TabComponent = (props: TabProps) => { - const nodeId = props.node.id; - const nodeType = props.nodeType as InventoryItemType; - const { sourceId } = useSourceContext(); - const { currentTimeRange } = useWaffleTimeContext(); - const { loading, metadata } = useMetadata({ - assetId: nodeId, - assetType: nodeType, - sourceId, - timeRange: currentTimeRange, - }); - const { - services: { osquery }, - } = useKibanaContextForPlugin(); - - // @ts-expect-error - const OsqueryAction = osquery?.OsqueryAction; - - // avoids component rerender when resizing the popover - const content = useMemo(() => { - // TODO: Add info when Osquery plugin is not available - if (loading || !OsqueryAction) { - return ( - - - - ); - } - - return ( - - - - ); - }, [OsqueryAction, loading, metadata]); - - return content; -}; - -export const OsqueryTab = { - id: 'osquery', - name: i18n.translate('xpack.infra.nodeDetails.tabs.osquery', { - defaultMessage: 'Osquery', - }), - content: TabComponent, -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/index.tsx deleted file mode 100644 index 2d4ef39864a45..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/index.tsx +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo, useState, useCallback } from 'react'; -import { debounce } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { - EuiSearchBar, - EuiSpacer, - EuiEmptyPrompt, - EuiButton, - EuiText, - EuiIconTip, - Query, -} from '@elastic/eui'; -import { getFieldByType } from '../../../../../../../../common/inventory_models'; -import { - useProcessList, - SortBy, - ProcessListContextProvider, -} from '../../../../hooks/use_process_list'; -import { TabContent, TabProps } from '../shared'; -import { STATE_NAMES } from './states'; -import { SummaryTable } from './summary_table'; -import { ProcessesTable } from './processes_table'; -import { parseSearchString } from './parse_search_string'; - -const TabComponent = ({ currentTime, node, nodeType }: TabProps) => { - const [searchBarState, setSearchBarState] = useState(Query.MATCH_ALL); - const [searchFilter, setSearchFilter] = useState(''); - const [sortBy, setSortBy] = useState({ - name: 'cpu', - isAscending: false, - }); - - const hostTerm = useMemo(() => { - const field = getFieldByType(nodeType) ?? nodeType; - return { [field]: node.name }; - }, [node, nodeType]); - - const { - loading, - error, - response, - makeRequest: reload, - } = useProcessList(hostTerm, currentTime, sortBy, parseSearchString(searchFilter)); - - const debouncedSearchOnChange = useMemo( - () => debounce<(queryText: string) => void>((queryText) => setSearchFilter(queryText), 500), - [setSearchFilter] - ); - - const searchBarOnChange = useCallback( - ({ query, queryText }) => { - setSearchBarState(query); - debouncedSearchOnChange(queryText); - }, - [setSearchBarState, debouncedSearchOnChange] - ); - - const clearSearchBar = useCallback(() => { - setSearchBarState(Query.MATCH_ALL); - setSearchFilter(''); - }, [setSearchBarState, setSearchFilter]); - - return ( - - - - - -

      - {i18n.translate('xpack.infra.metrics.nodeDetails.processesHeader', { - defaultMessage: 'Top processes', - })}{' '} - -

      -
      - - ({ - value, - view, - })), - }, - ]} - /> - - {!error ? ( - - ) : ( - - {i18n.translate('xpack.infra.metrics.nodeDetails.processListError', { - defaultMessage: 'Unable to load process data', - })} - - } - actions={ - - {i18n.translate('xpack.infra.metrics.nodeDetails.processListRetry', { - defaultMessage: 'Try again', - })} - - } - /> - )} -
      -
      - ); -}; - -export const ProcessesTab = { - id: 'processes', - name: i18n.translate('xpack.infra.metrics.nodeDetails.tabs.processes', { - defaultMessage: 'Processes', - }), - content: TabComponent, -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/parse_search_string.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/parse_search_string.ts deleted file mode 100644 index 7112dbed917a6..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/parse_search_string.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const parseSearchString = (query: string) => { - if (query.trim() === '') { - return [ - { - match_all: {}, - }, - ]; - } - const elements = query - .split(' ') - .map((s) => s.trim()) - .filter(Boolean); - const stateFilter = elements.filter((s) => s.startsWith('state=')); - const cmdlineFilters = elements.filter((s) => !s.startsWith('state=')); - return [ - ...cmdlineFilters.map((clause) => ({ - query_string: { - fields: ['system.process.cmdline'], - query: `*${escapeReservedCharacters(clause)}*`, - minimum_should_match: 1, - }, - })), - ...stateFilter.map((state) => ({ - match: { - 'system.process.state': state.replace('state=', ''), - }, - })), - ]; -}; - -const escapeReservedCharacters = (clause: string) => - clause.replace(/([+\-=!\(\)\{\}\[\]^"~*?:\\/!]|&&|\|\|)/g, '\\$1'); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx deleted file mode 100644 index c85cc1577fa7f..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx +++ /dev/null @@ -1,311 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo, useState, useCallback } from 'react'; -import { omit } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiTable, - EuiTableHeader, - EuiTableBody, - EuiTableHeaderCell, - EuiTableRowCell, - EuiLoadingChart, - EuiEmptyPrompt, - EuiText, - EuiLink, - EuiButton, - SortableProperties, - LEFT_ALIGNMENT, - RIGHT_ALIGNMENT, -} from '@elastic/eui'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { ProcessListAPIResponse } from '../../../../../../../../common/http_api'; -import { FORMATTERS } from '../../../../../../../../common/formatters'; -import { SortBy } from '../../../../hooks/use_process_list'; -import { Process } from './types'; -import { ProcessRow } from './process_row'; -import { StateBadge } from './state_badge'; -import { STATE_ORDER } from './states'; - -interface TableProps { - processList: ProcessListAPIResponse['processList']; - currentTime: number; - isLoading: boolean; - sortBy: SortBy; - setSortBy: (s: SortBy) => void; - clearSearchBar: () => void; -} - -function useSortableProperties( - sortablePropertyItems: Array<{ - name: string; - getValue: (obj: T) => any; - isAscending: boolean; - }>, - defaultSortProperty: string, - callback: (s: SortBy) => void -) { - const [sortableProperties] = useState>( - new SortableProperties(sortablePropertyItems, defaultSortProperty) - ); - - return { - updateSortableProperties: useCallback( - (property) => { - sortableProperties.sortOn(property); - callback(omit(sortableProperties.getSortedProperty(), 'getValue')); - }, - [sortableProperties, callback] - ), - }; -} - -export const ProcessesTable = ({ - processList, - currentTime, - isLoading, - sortBy, - setSortBy, - clearSearchBar, -}: TableProps) => { - const { updateSortableProperties } = useSortableProperties( - [ - { - name: 'startTime', - getValue: (item: any) => Date.parse(item.startTime), - isAscending: true, - }, - { - name: 'cpu', - getValue: (item: any) => item.cpu, - isAscending: false, - }, - { - name: 'memory', - getValue: (item: any) => item.memory, - isAscending: false, - }, - ], - 'cpu', - setSortBy - ); - - const currentItems = useMemo( - () => - processList.sort( - (a, b) => STATE_ORDER.indexOf(a.state) - STATE_ORDER.indexOf(b.state) - ) as Process[], - [processList] - ); - - if (isLoading) return ; - - if (currentItems.length === 0) - return ( - - {i18n.translate('xpack.infra.metrics.nodeDetails.noProcesses', { - defaultMessage: 'No processes found', - })} - - } - body={ - - - - - ), - }} - /> - - } - actions={ - - {i18n.translate('xpack.infra.metrics.nodeDetails.noProcessesClearFilters', { - defaultMessage: 'Clear filters', - })} - - } - /> - ); - - return ( - <> - - - - {columns.map((column) => ( - updateSortableProperties(column.field) : undefined} - isSorted={sortBy.name === column.field} - isSortAscending={sortBy.name === column.field && sortBy.isAscending} - > - {column.name} - - ))} - - - - - - - ); -}; - -const LoadingPlaceholder = () => { - return ( -
      - -
      - ); -}; - -interface TableBodyProps { - items: Process[]; - currentTime: number; -} -const ProcessesTableBody = ({ items, currentTime }: TableBodyProps) => ( - <> - {items.map((item, i) => { - const cells = columns.map((column) => ( - - {column.render ? column.render(item[column.field], currentTime) : item[column.field]} - - )); - return ; - })} - -); - -const StyledTableBody = euiStyled(EuiTableBody)` - & .euiTableCellContent { - padding-top: 0; - padding-bottom: 0; - - } -`; - -const ONE_MINUTE = 60 * 1000; -const ONE_HOUR = ONE_MINUTE * 60; -const RuntimeCell = ({ startTime, currentTime }: { startTime: number; currentTime: number }) => { - const runtimeLength = currentTime - startTime; - let remainingRuntimeMS = runtimeLength; - const runtimeHours = Math.floor(remainingRuntimeMS / ONE_HOUR); - remainingRuntimeMS -= runtimeHours * ONE_HOUR; - const runtimeMinutes = Math.floor(remainingRuntimeMS / ONE_MINUTE); - remainingRuntimeMS -= runtimeMinutes * ONE_MINUTE; - const runtimeSeconds = Math.floor(remainingRuntimeMS / 1000); - remainingRuntimeMS -= runtimeSeconds * 1000; - - const runtimeDisplayHours = runtimeHours ? `${runtimeHours}:` : ''; - const runtimeDisplayMinutes = runtimeMinutes < 10 ? `0${runtimeMinutes}:` : `${runtimeMinutes}:`; - const runtimeDisplaySeconds = runtimeSeconds < 10 ? `0${runtimeSeconds}` : runtimeSeconds; - - return <>{`${runtimeDisplayHours}${runtimeDisplayMinutes}${runtimeDisplaySeconds}`}; -}; - -const columns: Array<{ - field: keyof Process; - name: string; - sortable: boolean; - render?: Function; - width?: string | number; - textOnly?: boolean; - align?: typeof RIGHT_ALIGNMENT | typeof LEFT_ALIGNMENT; -}> = [ - { - field: 'state', - name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelState', { - defaultMessage: 'State', - }), - sortable: false, - render: (state: string) => , - width: 84, - textOnly: false, - }, - { - field: 'command', - name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelCommand', { - defaultMessage: 'Command', - }), - sortable: false, - width: '40%', - render: (command: string) => {command}, - }, - { - field: 'startTime', - name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelTime', { - defaultMessage: 'Time', - }), - align: RIGHT_ALIGNMENT, - sortable: true, - render: (startTime: number, currentTime: number) => ( - - ), - }, - { - field: 'cpu', - name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelCPU', { - defaultMessage: 'CPU', - }), - sortable: true, - render: (value: number) => FORMATTERS.percent(value), - }, - { - field: 'memory', - name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelMemory', { - defaultMessage: 'Mem.', - }), - sortable: true, - render: (value: number) => FORMATTERS.percent(value), - }, -]; - -const CodeLine = euiStyled.div` - font-family: ${(props) => props.theme.eui.euiCodeFontFamily}; - font-size: ${(props) => props.theme.eui.euiFontSizeS}; - white-space: pre; - overflow: hidden; - text-overflow: ellipsis; -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/state_badge.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/state_badge.tsx deleted file mode 100644 index 47049c7d9c893..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/state_badge.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiBadge } from '@elastic/eui'; -import { STATE_NAMES } from './states'; - -export const StateBadge = ({ state }: { state: string }) => { - switch (state) { - case 'running': - return {STATE_NAMES.running}; - case 'sleeping': - return {STATE_NAMES.sleeping}; - case 'dead': - return {STATE_NAMES.dead}; - case 'stopped': - return {STATE_NAMES.stopped}; - case 'idle': - return {STATE_NAMES.idle}; - case 'zombie': - return {STATE_NAMES.zombie}; - default: - return {STATE_NAMES.unknown}; - } -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/states.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/states.ts deleted file mode 100644 index ea944cd8bb8c0..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/states.ts +++ /dev/null @@ -1,34 +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 { i18n } from '@kbn/i18n'; - -export const STATE_NAMES = { - running: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateRunning', { - defaultMessage: 'Running', - }), - sleeping: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateSleeping', { - defaultMessage: 'Sleeping', - }), - dead: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateDead', { - defaultMessage: 'Dead', - }), - stopped: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateStopped', { - defaultMessage: 'Stopped', - }), - idle: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateIdle', { - defaultMessage: 'Idle', - }), - zombie: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateZombie', { - defaultMessage: 'Zombie', - }), - unknown: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateUnknown', { - defaultMessage: 'Unknown', - }), -}; - -export const STATE_ORDER = ['running', 'sleeping', 'stopped', 'idle', 'dead', 'zombie', 'unknown']; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/summary_table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/summary_table.tsx deleted file mode 100644 index 61e5cde421181..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/summary_table.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo } from 'react'; -import { mapValues } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { - EuiLoadingSpinner, - EuiFlexGroup, - EuiFlexItem, - EuiDescriptionList, - EuiDescriptionListTitle, - EuiDescriptionListDescription, - EuiHorizontalRule, -} from '@elastic/eui'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { ProcessListAPIResponse } from '../../../../../../../../common/http_api'; -import { STATE_NAMES } from './states'; - -interface Props { - processSummary: ProcessListAPIResponse['summary']; - isLoading: boolean; -} - -type SummaryRecord = { - total: number; -} & Record; - -const NOT_AVAILABLE_LABEL = i18n.translate('xpack.infra.notAvailableLabel', { - defaultMessage: 'N/A', -}); - -const processSummaryNotAvailable = { - total: NOT_AVAILABLE_LABEL, - running: NOT_AVAILABLE_LABEL, - sleeping: NOT_AVAILABLE_LABEL, - dead: NOT_AVAILABLE_LABEL, - stopped: NOT_AVAILABLE_LABEL, - idle: NOT_AVAILABLE_LABEL, - zombie: NOT_AVAILABLE_LABEL, - unknown: NOT_AVAILABLE_LABEL, -}; - -export const SummaryTable = ({ processSummary, isLoading }: Props) => { - const summary = !processSummary?.total ? processSummaryNotAvailable : processSummary; - - const processCount = useMemo( - () => - ({ - total: isLoading ? -1 : summary.total, - ...mapValues(STATE_NAMES, () => (isLoading ? -1 : 0)), - ...(isLoading ? {} : summary), - } as SummaryRecord), - [summary, isLoading] - ); - return ( - <> - - {Object.entries(processCount).map(([field, value]) => ( - - - {columnTitles[field as keyof SummaryRecord]} - - {value === -1 ? : value} - - - - ))} - - - - ); -}; - -const columnTitles = { - total: i18n.translate('xpack.infra.metrics.nodeDetails.processes.headingTotalProcesses', { - defaultMessage: 'Total processes', - }), - ...STATE_NAMES, -}; - -const LoadingSpinner = euiStyled(EuiLoadingSpinner).attrs({ size: 'm' })` - margin-top: 2px; - margin-bottom: 3px; -`; - -const ColumnTitle = euiStyled(EuiDescriptionListTitle)` - white-space: nowrap; -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/types.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/types.ts deleted file mode 100644 index 1b38ac3829960..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/types.ts +++ /dev/null @@ -1,23 +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 { MetricsExplorerSeries } from '../../../../../../../../common/http_api'; -import { STATE_NAMES } from './states'; - -export interface Process { - command: string; - cpu: number; - memory: number; - startTime: number; - state: keyof typeof STATE_NAMES; - pid: number; - user: string; - timeseries: { - [x: string]: MetricsExplorerSeries; - }; - apmTrace?: string; // Placeholder -} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/build_fields.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/build_fields.ts deleted file mode 100644 index 8b77242055a1f..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/build_fields.ts +++ /dev/null @@ -1,117 +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 { InfraMetadata } from '../../../../../../../../common/http_api'; - -export const getFields = (metadata: InfraMetadata, group: 'cloud' | 'host' | 'agent') => { - switch (group) { - case 'host': - return prune([ - { - name: 'host.architecture', - value: metadata.info?.host?.architecture, - }, - { - name: 'host.hostname', - value: metadata.info?.host?.name, - }, - { - name: 'host.id', - value: metadata.info?.host?.id, - }, - { - name: 'host.ip', - value: metadata.info?.host?.ip, - }, - { - name: 'host.mac', - value: metadata.info?.host?.mac, - }, - { - name: 'host.name', - value: metadata.info?.host?.name, - }, - { - name: 'host.os.build', - value: metadata.info?.host?.os?.build, - }, - { - name: 'host.os.family', - value: metadata.info?.host?.os?.family, - }, - { - name: 'host.os.name', - value: metadata.info?.host?.os?.name, - }, - { - name: 'host.os.kernel', - value: metadata.info?.host?.os?.kernel, - }, - { - name: 'host.os.platform', - value: metadata.info?.host?.os?.platform, - }, - { - name: 'host.os.version', - value: metadata.info?.host?.os?.version, - }, - ]); - case 'cloud': - return prune([ - { - name: 'cloud.account.id', - value: metadata.info?.cloud?.account?.id, - }, - { - name: 'cloud.account.name', - value: metadata.info?.cloud?.account?.name, - }, - { - name: 'cloud.availability_zone', - value: metadata.info?.cloud?.availability_zone, - }, - { - name: 'cloud.instance.id', - value: metadata.info?.cloud?.instance?.id, - }, - { - name: 'cloud.instance.name', - value: metadata.info?.cloud?.instance?.name, - }, - { - name: 'cloud.machine.type', - value: metadata.info?.cloud?.machine?.type, - }, - { - name: 'cloud.provider', - value: metadata.info?.cloud?.provider, - }, - { - name: 'cloud.region', - value: metadata.info?.cloud?.region, - }, - ]); - case 'agent': - return prune([ - { - name: 'agent.id', - value: metadata.info?.agent?.id, - }, - { - name: 'agent.version', - value: metadata.info?.agent?.version, - }, - { - name: 'agent.policy', - value: metadata.info?.agent?.policy, - }, - ]); - } -}; - -const prune = (fields: Array<{ name: string; value: string | string[] | undefined }>) => - fields.filter((f) => !!f.value); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx deleted file mode 100644 index c8550a4d05163..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiLoadingChart } from '@elastic/eui'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { TabContent, TabProps } from '../shared'; -import { useSourceContext } from '../../../../../../../containers/metrics_source'; -import { InventoryItemType } from '../../../../../../../../common/inventory_models/types'; -import { useMetadata } from '../../../../../../../components/asset_details/hooks/use_metadata'; -import { getFields } from './build_fields'; -import { useWaffleTimeContext } from '../../../../hooks/use_waffle_time'; -import { Table } from './table'; -import { useWaffleFiltersContext } from '../../../../hooks/use_waffle_filters'; - -const TabComponent = (props: TabProps) => { - const nodeId = props.node.id; - const nodeType = props.nodeType as InventoryItemType; - const { sourceId } = useSourceContext(); - const { currentTimeRange } = useWaffleTimeContext(); - const { applyFilterQuery } = useWaffleFiltersContext(); - const { loading: metadataLoading, metadata } = useMetadata({ - assetId: nodeId, - assetType: nodeType, - sourceId, - timeRange: currentTimeRange, - }); - - const hostFields = useMemo(() => { - if (!metadata) return null; - return getFields(metadata, 'host'); - }, [metadata]); - - const cloudFields = useMemo(() => { - if (!metadata) return null; - return getFields(metadata, 'cloud'); - }, [metadata]); - - const agentFields = useMemo(() => { - if (!metadata) return null; - return getFields(metadata, 'agent'); - }, [metadata]); - - const onFilter = useCallback( - (item: { name: string; value: string }) => { - applyFilterQuery({ - kind: 'kuery', - expression: `${item.name}: "${item.value}"`, - }); - }, - [applyFilterQuery] - ); - - if (metadataLoading) { - return ; - } - - return ( - - {hostFields && hostFields.length > 0 && ( - - - - )} - {cloudFields && cloudFields.length > 0 && ( - -
      - - )} - {agentFields && agentFields.length > 0 && ( - -
      - - )} - - ); -}; - -const TableWrapper = euiStyled.div` - &:not(:last-child) { - margin-bottom: 16px - } -`; - -const LoadingPlaceholder = () => { - return ( -
      - -
      - ); -}; - -export const PropertiesTab = { - id: 'properties', - name: i18n.translate('xpack.infra.nodeDetails.tabs.metadata.title', { - defaultMessage: 'Metadata', - }), - content: TabComponent, -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx deleted file mode 100644 index 01b047f2e3664..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx +++ /dev/null @@ -1,167 +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 { - EuiText, - EuiToolTip, - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiBasicTable, - EuiSpacer, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import useToggle from 'react-use/lib/useToggle'; - -interface Row { - name: string; - value: string | string[] | undefined; -} - -interface Props { - rows: Row[]; - title: string; - onClick(item: Row): void; -} - -export const Table = (props: Props) => { - const { rows, title, onClick } = props; - const columns = useMemo( - () => [ - { - field: 'name', - name: '', - width: '35%', - sortable: false, - render: (name: string, item: Row) => ( - - {item.name} - - ), - }, - { - field: 'value', - name: '', - width: '65%', - sortable: false, - render: (_name: string, item: Row) => { - return ( - - - - - onClick(item)} - /> - - - - - - - - ); - }, - }, - ], - [onClick] - ); - - return ( - <> - -

      {title}

      -
      - - - - ); -}; - -class TableWithoutHeader extends EuiBasicTable { - renderTableHead() { - return <>; - } -} - -interface ExpandableContentProps { - values: string | string[] | undefined; -} - -const ExpandableContent = (props: ExpandableContentProps) => { - const { values } = props; - const [isExpanded, toggle] = useToggle(false); - - const list = Array.isArray(values) ? values : [values]; - const [first, ...others] = list; - const hasOthers = others.length > 0; - const shouldShowMore = hasOthers && !isExpanded; - - return ( - -
      - {first} - {shouldShowMore && ( - <> - {' ... '} - - - - - )} -
      - {isExpanded && others.map((item) => {item})} - {hasOthers && isExpanded && ( - - - {i18n.translate('xpack.infra.nodeDetails.tabs.metadata.seeLess', { - defaultMessage: 'Show less', - })} - - - )} -
      - ); -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx deleted file mode 100644 index 2d65ef9c01fc6..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { InventoryItemType } from '../../../../../../../common/inventory_models/types'; -import { InfraWaffleMapOptions, InfraWaffleMapNode } from '../../../../../../lib/lib'; - -export interface TabProps { - options: InfraWaffleMapOptions; - currentTime: number; - node: InfraWaffleMapNode; - nodeType: InventoryItemType; - onClose(): void; -} - -export const OVERLAY_Y_START = 266; -export const OVERLAY_BOTTOM_MARGIN = 16; -export const TabContent = euiStyled.div` - padding: ${(props) => props.theme.eui.euiSizeM}; - flex: 1; - overflow-y: auto; - overflow-x: hidden; -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx index c7d5e425efbab..a0ffd01ceee4f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx @@ -19,6 +19,8 @@ import { TableView } from './table_view'; import { SnapshotNode } from '../../../../../common/http_api/snapshot_api'; import { calculateBoundsFromNodes } from '../lib/calculate_bounds_from_nodes'; import { Legend } from './waffle/legend'; +import { useAssetDetailsFlyoutState } from '../hooks/use_asset_details_flyout_url_state'; +import { AssetDetailsFlyout } from './waffle/asset_details_flyout'; export interface KueryFilterQuery { kind: 'kuery'; @@ -57,6 +59,12 @@ export const NodesOverview = ({ showLoading, }: Props) => { const currentBreakpoint = useCurrentEuiBreakpoint(); + const [{ detailsItemId }, setFlyoutUrlState] = useAssetDetailsFlyoutState(); + + const closeFlyout = useCallback( + () => setFlyoutUrlState({ detailsItemId: null }), + [setFlyoutUrlState] + ); const handleDrilldown = useCallback( (filter: string) => { @@ -123,6 +131,7 @@ export const NodesOverview = ({ + {nodeType === 'host' && detailsItemId && ( + + )} void; + currentTime: number; +} + +const ONE_HOUR = 60 * 60 * 1000; + +const flyoutTabs = [ + ...commonFlyoutTabs, + { + id: ContentTabIds.LINK_TO_APM, + name: i18n.translate('xpack.infra.nodeDetails.tabs.linkToApm', { + defaultMessage: 'APM', + }), + }, +]; + +export const AssetDetailsFlyout = ({ assetName, assetType, closeFlyout, currentTime }: Props) => { + const { source } = useSourceContext(); + + return source ? ( + + ) : null; +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_of_groups.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_of_groups.tsx index 634d98f28d8b5..76dd103a25d1a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_of_groups.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_of_groups.tsx @@ -25,6 +25,7 @@ interface Props { bounds: InfraWaffleMapBounds; nodeType: InventoryItemType; currentTime: number; + detailsItemId: string | null; } export const GroupOfGroups: React.FC = (props) => { @@ -43,6 +44,7 @@ export const GroupOfGroups: React.FC = (props) => { bounds={props.bounds} nodeType={props.nodeType} currentTime={props.currentTime} + detailsItemId={props.detailsItemId} /> ))} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_of_nodes.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_of_nodes.tsx index 043586b682693..1fe78bfdc142d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_of_nodes.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_of_nodes.tsx @@ -17,6 +17,7 @@ import { import { GroupName } from './group_name'; import { Node } from './node'; import { InventoryItemType } from '../../../../../../common/inventory_models/types'; +import { useAssetDetailsFlyoutState } from '../../hooks/use_asset_details_flyout_url_state'; interface Props { onDrilldown: (filter: string) => void; @@ -27,6 +28,7 @@ interface Props { bounds: InfraWaffleMapBounds; nodeType: InventoryItemType; currentTime: number; + detailsItemId: string | null; } // custom comparison function for rendering the nodes to prevent unncessary rerendering @@ -42,8 +44,20 @@ const isEqualGroupOfNodes = (prevProps: Props, nextProps: Props) => { }; export const GroupOfNodes = React.memo( - ({ group, options, formatter, onDrilldown, isChild = false, bounds, nodeType, currentTime }) => { + ({ + group, + options, + formatter, + onDrilldown, + isChild = false, + bounds, + nodeType, + currentTime, + detailsItemId, + }) => { const width = group.width > 200 ? group.width : 200; + const [_, setFlyoutUrlState] = useAssetDetailsFlyoutState(); + return ( @@ -59,6 +73,8 @@ export const GroupOfNodes = React.memo( bounds={bounds} nodeType={nodeType} currentTime={currentTime} + detailsItemId={detailsItemId} + setFlyoutUrlState={setFlyoutUrlState} /> )) ) : ( diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/map.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/map.tsx index 6a0a6dad79eab..ceff6663fd7c3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/map.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/map.tsx @@ -30,6 +30,7 @@ interface Props { dataBounds: InfraWaffleMapBounds; bottomMargin: number; staticHeight: boolean; + detailsItemId: string | null; } export const Map: React.FC = ({ @@ -43,6 +44,7 @@ export const Map: React.FC = ({ dataBounds, bottomMargin, staticHeight, + detailsItemId, }) => { const sortedNodes = sortNodes(options.sort, nodes); const map = nodesToWaffleMap(sortedNodes); @@ -70,6 +72,7 @@ export const Map: React.FC = ({ bounds={bounds} nodeType={nodeType} currentTime={currentTime} + detailsItemId={detailsItemId} /> ); } @@ -85,6 +88,7 @@ export const Map: React.FC = ({ bounds={bounds} nodeType={nodeType} currentTime={currentTime} + detailsItemId={detailsItemId} /> ); } diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node.tsx index 0eda4c4b72cd1..c539d8faaf3e2 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node.tsx @@ -5,14 +5,11 @@ * 2.0. */ -import { darken, readableColor } from 'polished'; import React from 'react'; -import { i18n } from '@kbn/i18n'; - import { first } from 'lodash'; import { EuiPopover, EuiToolTip } from '@elastic/eui'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { useBoolean } from '../../../../../hooks/use_boolean'; import { InfraWaffleMapBounds, InfraWaffleMapNode, @@ -21,20 +18,10 @@ import { import { ConditionalToolTip } from './conditional_tooltip'; import { colorFromValue } from '../../lib/color_from_value'; import { InventoryItemType } from '../../../../../../common/inventory_models/types'; -import { NodeContextPopover } from '../node_details/overlay'; import { NodeContextMenu } from './node_context_menu'; -import { AlertFlyout } from '../../../../../alerting/inventory/components/alert_flyout'; -import { findInventoryFields } from '../../../../../../common/inventory_models'; - -const initialState = { - isPopoverOpen: false, - isOverlayOpen: false, - isAlertFlyoutVisible: false, - isToolTipOpen: false, -}; - -type State = Readonly; +import { NodeSquare } from './node_square'; +import { type AssetDetailsFlyoutPropertiesUpdater } from '../../hooks/use_asset_details_flyout_url_state'; interface Props { squareSize: number; @@ -44,247 +31,78 @@ interface Props { bounds: InfraWaffleMapBounds; nodeType: InventoryItemType; currentTime: number; + setFlyoutUrlState: AssetDetailsFlyoutPropertiesUpdater; + detailsItemId: string | null; } -export class Node extends React.PureComponent { - public readonly state: State = initialState; - public render() { - const { nodeType, node, options, squareSize, bounds, formatter, currentTime } = this.props; - const { isPopoverOpen, isAlertFlyoutVisible, isToolTipOpen } = this.state; - const metric = first(node.metrics); - const valueMode = squareSize > 70; - const ellipsisMode = squareSize > 30; - const rawValue = (metric && metric.value) || 0; - const color = colorFromValue(options.legend, rawValue, bounds); - const value = formatter(rawValue); - const nodeAriaLabel = i18n.translate('xpack.infra.node.ariaLabel', { - defaultMessage: '{nodeName}, click to open menu', - values: { nodeName: node.name }, - }); - - const nodeBorder = this.state.isOverlayOpen ? { border: 'solid 4px #000' } : undefined; - - const bigSquare = ( - - - - {valueMode ? ( - - - - {value} - - - ) : ( - ellipsisMode && ( - - - - ) - )} - - - - ); - - const smallSquare = ( - - ); - - const nodeSquare = valueMode || ellipsisMode ? bigSquare : smallSquare; - - return ( - <> - {isPopoverOpen ? ( - - - - ) : isToolTipOpen ? ( - - } - > - {nodeSquare} - - ) : ( - nodeSquare - )} +export const Node = ({ + nodeType, + node, + options, + squareSize, + bounds, + formatter, + currentTime, + setFlyoutUrlState, + detailsItemId, +}: Props) => { + const [isToolTipOpen, { off: hideToolTip, on: showToolTip }] = useBoolean(false); + const [isPopoverOpen, { off: closePopover, toggle: togglePopover }] = useBoolean(false); + + const metric = first(node.metrics); + const rawValue = (metric && metric.value) || 0; + const color = colorFromValue(options.legend, rawValue, bounds); + const value = formatter(rawValue); + + const toggleAssetPopover = () => { + if (nodeType === 'host') { + setFlyoutUrlState({ detailsItemId: node.name }); + } else { + togglePopover(); + } + }; - {this.state.isOverlayOpen && ( - + ); + + return ( + <> + {isPopoverOpen ? ( + + - )} - - {isAlertFlyoutVisible && ( - - )} - - ); - } - - private openAlertFlyout = () => { - this.setState({ - isOverlayOpen: false, - isAlertFlyoutVisible: true, - }); - }; - - private setAlertFlyoutVisible = (isOpen: boolean) => { - this.setState({ - isAlertFlyoutVisible: isOpen, - }); - }; - - private togglePopover = () => { - const { nodeType } = this.props; - if (nodeType === 'host') { - this.toggleNewOverlay(); - } else { - this.setState((prevState) => ({ isPopoverOpen: !prevState.isPopoverOpen })); - } - }; - - private toggleNewOverlay = () => { - this.setState((prevState) => ({ - isPopoverOpen: !prevState.isOverlayOpen === true ? false : prevState.isPopoverOpen, - isOverlayOpen: !prevState.isOverlayOpen, - })); - }; - - private closePopover = () => { - if (this.state.isPopoverOpen) { - this.setState({ isPopoverOpen: false }); - } - }; - private showToolTip = () => { - this.setState({ isToolTipOpen: true }); - }; - private hideToolTip = () => { - this.setState({ isToolTipOpen: false }); - }; -} - -const NodeContainer = euiStyled.div` - position: relative; - cursor: pointer; -`; -const NodeContainerSmall = euiStyled.div` - cursor: pointer; - position: relative; - background-color: ${(props) => darken(0.1, props.color)}; - border-radius: 3px; - margin: 2px; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); -`; - -interface ColorProps { - color: string; -} - -const SquareOuter = euiStyled.div` - position: absolute; - top: 4px; - left: 4px; - bottom: 4px; - right: 4px; - background-color: ${(props) => darken(0.1, props.color)}; - border-radius: 3px; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); -`; - -const SquareInner = euiStyled.div` - position: absolute; - top: 0; - right: 0; - bottom: 2px; - left: 0; - border-radius: 3px; - background-color: ${(props) => props.color}; -`; - -const ValueInner = euiStyled.button` - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - line-height: 1.2em; - align-items: center; - align-content: center; - padding: 1em; - overflow: hidden; - flex-wrap: wrap; - width: 100%; - border: none; - &:focus { - outline: none !important; - border: ${(params) => params.theme?.eui.euiFocusRingSize} solid - ${(params) => params.theme?.eui.euiFocusRingColor}; - box-shadow: none; - } -`; - -const SquareTextContent = euiStyled.div` - text-align: center; - width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1 0 auto; - color: ${(props) => readableColor(props.color)}; -`; - -const Value = euiStyled(SquareTextContent)` - font-weight: bold; - font-size: 0.9em; - line-height: 1.2em; -`; - -const Label = euiStyled(SquareTextContent)` - font-size: 0.7em; - margin-bottom: 0.7em; -`; + + ) : isToolTipOpen ? ( + } + > + {nodeSquare} + + ) : ( + nodeSquare + )} + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_square.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_square.tsx new file mode 100644 index 0000000000000..cb97b563af929 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_square.tsx @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { darken, readableColor } from 'polished'; +import React, { CSSProperties } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { DispatchWithOptionalAction } from '../../../../../hooks/use_boolean'; + +const SquareTextContentStyles = (color: string) => ` + text-align: center; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 0 auto; + color: ${readableColor(color)}; +`; +const styles = { + nodeContainerSmall: (color: string) => ` + cursor: pointer; + position: relative; + background-color: ${darken(0.1, color)}; + border-radius: 3px; + margin: 2px; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); + `, + valueInner: ` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + line-height: 1.2em; + align-items: center; + align-content: center; + padding: 1em; + overflow: hidden; + flex-wrap: wrap; + width: 100%; + border: none; + &:focus { + outline: none !important; + border: ${euiThemeVars.euiFocusRingSize} solid ${euiThemeVars.euiFocusRingColor}; + box-shadow: none; + } + `, + squareOuter: (color: string) => ` + position: absolute; + top: 4px; + left: 4px; + bottom: 4px; + right: 4px; + background-color: ${darken(0.1, color)}; + border-radius: 3px; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); + `, + squareInner: (color: string) => ` + position: absolute; + top: 0; + right: 0; + bottom: 2px; + left: 0; + border-radius: 3px; + background-color: ${color}; + `, + label: (color: string) => ` + font-size: 0.7em; + margin-bottom: 0.7em; + ${SquareTextContentStyles(color)} + `, + value: (color: string) => ` + font-weight: bold; + font-size: 0.9em; + line-height: 1.2em; + ${SquareTextContentStyles(color)} + `, +}; + +export const NodeSquare = ({ + squareSize, + togglePopover, + showToolTip, + hideToolTip, + color, + nodeName, + value, + showBorder, +}: { + squareSize: number; + togglePopover: DispatchWithOptionalAction; + showToolTip: () => void; + hideToolTip: () => void; + color: string; + nodeName: string; + value: string; + showBorder?: boolean; +}) => { + const valueMode = squareSize > 70; + const ellipsisMode = squareSize > 30; + const nodeAriaLabel = i18n.translate('xpack.infra.node.ariaLabel', { + defaultMessage: '{nodeName}, click to open menu', + values: { nodeName }, + }); + const style: CSSProperties | undefined = showBorder ? { border: 'solid 4px #000' } : undefined; + + return valueMode || ellipsisMode ? ( +
      +
      +
      + {valueMode ? ( + + ) : ( + ellipsisMode && ( + + ) + )} +
      +
      +
      + ) : ( +
      + ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_asset_details_flyout_url_state.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_asset_details_flyout_url_state.ts new file mode 100644 index 0000000000000..7847cfc0da268 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_asset_details_flyout_url_state.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { constant, identity } from 'fp-ts/lib/function'; +import { useUrlState } from '../../../../utils/use_url_state'; + +export const GET_DEFAULT_PROPERTIES: AssetDetailsFlyoutProperties = { + detailsItemId: null, +}; + +const ASSET_DETAILS_FLYOUT_URL_STATE_KEY = 'assetDetailsFlyout'; + +export const useAssetDetailsFlyoutState = (): [ + AssetDetailsFlyoutProperties, + AssetDetailsFlyoutPropertiesUpdater +] => { + const [urlState, setUrlState] = useUrlState({ + defaultState: { + ...GET_DEFAULT_PROPERTIES, + }, + decodeUrlState, + encodeUrlState, + urlStateKey: ASSET_DETAILS_FLYOUT_URL_STATE_KEY, + }); + + return [urlState, setUrlState]; +}; + +const AssetDetailsFlyoutStateRT = rt.type({ + detailsItemId: rt.union([rt.string, rt.null]), +}); + +export type AssetDetailsFlyoutState = rt.TypeOf; +export type AssetDetailsFlyoutPropertiesUpdater = (params: AssetDetailsFlyoutState) => void; + +type AssetDetailsFlyoutProperties = rt.TypeOf; + +const encodeUrlState = AssetDetailsFlyoutStateRT.encode; +const decodeUrlState = (value: unknown) => { + return pipe(AssetDetailsFlyoutStateRT.decode(value), fold(constant(undefined), identity)); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/asset_detail_page.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/asset_detail_page.tsx index d5ba376a514bb..f5809c0857353 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/asset_detail_page.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/asset_detail_page.tsx @@ -7,54 +7,14 @@ import React from 'react'; import { useRouteMatch } from 'react-router-dom'; -import { i18n } from '@kbn/i18n'; import { NoRemoteCluster } from '../../../components/empty_states'; import { SourceErrorPage } from '../../../components/source_error_page'; import { SourceLoadingPage } from '../../../components/source_loading_page'; import { useSourceContext } from '../../../containers/metrics_source'; -import { ContentTabIds, type Tab } from '../../../components/asset_details/types'; import type { InventoryItemType } from '../../../../common/inventory_models/types'; import { AssetDetails } from '../../../components/asset_details/asset_details'; import { MetricsPageTemplate } from '../page_template'; - -const orderedFlyoutTabs: Tab[] = [ - { - id: ContentTabIds.OVERVIEW, - name: i18n.translate('xpack.infra.nodeDetails.tabs.overview.title', { - defaultMessage: 'Overview', - }), - }, - { - id: ContentTabIds.METADATA, - name: i18n.translate('xpack.infra.nodeDetails.tabs.metadata.title', { - defaultMessage: 'Metadata', - }), - }, - { - id: ContentTabIds.PROCESSES, - name: i18n.translate('xpack.infra.metrics.nodeDetails.tabs.processes', { - defaultMessage: 'Processes', - }), - }, - { - id: ContentTabIds.LOGS, - name: i18n.translate('xpack.infra.nodeDetails.tabs.logs.title', { - defaultMessage: 'Logs', - }), - }, - { - id: ContentTabIds.ANOMALIES, - name: i18n.translate('xpack.infra.nodeDetails.tabs.anomalies', { - defaultMessage: 'Anomalies', - }), - }, - { - id: ContentTabIds.OSQUERY, - name: i18n.translate('xpack.infra.nodeDetails.tabs.osquery', { - defaultMessage: 'Osquery', - }), - }, -]; +import { commonFlyoutTabs } from '../../../common/asset_details_config/asset_details_tabs'; export const AssetDetailPage = () => { const { isLoading, loadSourceFailureMessage, loadSource, source } = useSourceContext(); @@ -91,7 +51,7 @@ export const AssetDetailPage = () => { id: nodeId, }} assetType={nodeType} - tabs={orderedFlyoutTabs} + tabs={commonFlyoutTabs} links={['apmServices']} renderMode={{ mode: 'page', diff --git a/x-pack/plugins/observability/kibana.jsonc b/x-pack/plugins/observability/kibana.jsonc index 5064e06b156e0..5410d58ae7c92 100644 --- a/x-pack/plugins/observability/kibana.jsonc +++ b/x-pack/plugins/observability/kibana.jsonc @@ -30,7 +30,8 @@ "security", "share", "unifiedSearch", - "visualizations" + "visualizations", + "dashboard", ], "optionalPlugins": ["discover", "home", "licensing", "usageCollection", "cloud", "spaces"], "requiredBundles": ["data", "kibanaReact", "kibanaUtils", "unifiedSearch", "cloudChat", "stackAlerts", "spaces"], diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/handle_explicit_input.tsx b/x-pack/plugins/observability/public/embeddable/slo/overview/handle_explicit_input.tsx new file mode 100644 index 0000000000000..0c36b4e915c6c --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/handle_explicit_input.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { toMountPoint } from '@kbn/react-kibana-mount'; + +import type { CoreStart } from '@kbn/core/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { EmbeddableSloProps, SloEmbeddableInput } from './types'; + +import { ObservabilityPublicPluginsStart } from '../../..'; +import { SloConfiguration } from './slo_configuration'; +export async function resolveEmbeddableSloUserInput( + coreStart: CoreStart, + pluginStart: ObservabilityPublicPluginsStart, + input?: SloEmbeddableInput +): Promise { + const { overlays } = coreStart; + const queryClient = new QueryClient(); + return new Promise(async (resolve, reject) => { + try { + const modalSession = overlays.openModal( + toMountPoint( + + + { + modalSession.close(); + resolve(update); + }} + onCancel={() => { + modalSession.close(); + reject(); + }} + /> + + , + { i18n: coreStart.i18n, theme: coreStart.theme } + ) + ); + } catch (error) { + reject(error); + } + }); +} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/index.tsx b/x-pack/plugins/observability/public/embeddable/slo/overview/index.ts similarity index 75% rename from x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/index.tsx rename to x-pack/plugins/observability/public/embeddable/slo/overview/index.ts index 6cc79a1c32375..9cc48e8c635f2 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/index.tsx +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './metrics'; +export { SloOverviewEmbeddableFactoryDefinition } from './slo_embeddable_factory'; diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_configuration.tsx b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_configuration.tsx new file mode 100644 index 0000000000000..cf83690800318 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_configuration.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { SloSelector } from './slo_selector'; + +import type { EmbeddableSloProps } from './types'; + +interface SloConfigurationProps { + onCreate: (props: EmbeddableSloProps) => void; + onCancel: () => void; +} + +export function SloConfiguration({ onCreate, onCancel }: SloConfigurationProps) { + const [selectedSlo, setSelectedSlo] = useState(); + const onConfirmClick = () => + onCreate({ sloId: selectedSlo?.sloId, sloInstanceId: selectedSlo?.sloInstanceId }); + const [hasError, setHasError] = useState(false); + + return ( + + + + {i18n.translate('xpack.observability.sloEmbeddable.config.sloSelector.headerTitle', { + defaultMessage: 'SLO configuration', + })} + + + + + + { + if (slo === undefined) { + setHasError(true); + } else { + setHasError(false); + } + setSelectedSlo({ sloId: slo?.id, sloInstanceId: slo?.instanceId }); + }} + /> + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable.tsx b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable.tsx new file mode 100644 index 0000000000000..faadbcb637646 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Subscription } from 'rxjs'; +import { i18n } from '@kbn/i18n'; + +import { + Embeddable as AbstractEmbeddable, + EmbeddableOutput, + IContainer, +} from '@kbn/embeddable-plugin/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { type CoreStart, IUiSettingsClient, ApplicationStart } from '@kbn/core/public'; +import { SloOverview } from './slo_overview'; +import type { SloEmbeddableInput } from './types'; + +export const SLO_EMBEDDABLE = 'SLO_EMBEDDABLE'; + +interface SloEmbeddableDeps { + uiSettings: IUiSettingsClient; + http: CoreStart['http']; + i18n: CoreStart['i18n']; + application: ApplicationStart; +} + +export class SLOEmbeddable extends AbstractEmbeddable { + public readonly type = SLO_EMBEDDABLE; + private subscription: Subscription; + private node?: HTMLElement; + + constructor( + private readonly deps: SloEmbeddableDeps, + initialInput: SloEmbeddableInput, + parent?: IContainer + ) { + super(initialInput, {}, parent); + + this.subscription = new Subscription(); + this.subscription.add(this.getInput$().subscribe(() => this.reload())); + } + + setTitle(title: string) { + this.updateInput({ title }); + } + + public render(node: HTMLElement) { + this.node = node; + this.setTitle( + this.input.title || + i18n.translate('xpack.observability.sloEmbeddable.displayTitle', { + defaultMessage: 'SLO Overview', + }) + ); + this.input.lastReloadRequestTime = Date.now(); + + const { sloId, sloInstanceId } = this.getInput(); + const queryClient = new QueryClient(); + + const I18nContext = this.deps.i18n.Context; + ReactDOM.render( + + + + + + + , + node + ); + } + + public reload() { + if (this.node) { + this.render(this.node); + } + } + + public destroy() { + super.destroy(); + this.subscription.unsubscribe(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } +} diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable_factory.ts b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable_factory.ts new file mode 100644 index 0000000000000..7adb76eb9acfe --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable_factory.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { CoreSetup } from '@kbn/core/public'; +import { + IContainer, + EmbeddableFactoryDefinition, + EmbeddableFactory, + ErrorEmbeddable, +} from '@kbn/embeddable-plugin/public'; +import { SLOEmbeddable, SLO_EMBEDDABLE } from './slo_embeddable'; +import { ObservabilityPublicPluginsStart, ObservabilityPublicStart } from '../../..'; +import type { SloEmbeddableInput } from './types'; + +export type SloOverviewEmbeddableFactory = EmbeddableFactory; +export class SloOverviewEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { + public readonly type = SLO_EMBEDDABLE; + + constructor( + private getStartServices: CoreSetup< + ObservabilityPublicPluginsStart, + ObservabilityPublicStart + >['getStartServices'] + ) {} + + public async isEditable() { + return true; + } + + public async getExplicitInput(): Promise> { + const [coreStart, pluginStart] = await this.getStartServices(); + try { + const { resolveEmbeddableSloUserInput } = await import('./handle_explicit_input'); + return await resolveEmbeddableSloUserInput(coreStart, pluginStart); + } catch (e) { + return Promise.reject(); + } + } + + public async create(initialInput: SloEmbeddableInput, parent?: IContainer) { + try { + const [{ uiSettings, application, http, i18n: i18nService }] = await this.getStartServices(); + return new SLOEmbeddable( + { uiSettings, application, http, i18n: i18nService }, + initialInput, + parent + ); + } catch (e) { + return new ErrorEmbeddable(e, initialInput, parent); + } + } + + public getDescription() { + return i18n.translate('xpack.observability.sloEmbeddable.description', { + defaultMessage: 'Get an overview of your SLO health', + }); + } + + public getDisplayName() { + return i18n.translate('xpack.observability.sloEmbeddable.displayName', { + defaultMessage: 'SLO Overview', + }); + } + + public getIconType() { + return 'visGauge'; + } +} diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview.tsx b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview.tsx new file mode 100644 index 0000000000000..5e8947a6c5ba9 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview.tsx @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, { useCallback, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiIcon, useEuiBackgroundColor } from '@elastic/eui'; +import { Chart, Metric, MetricTrendShape, Settings } from '@elastic/charts'; +import numeral from '@elastic/numeral'; +import { ALL_VALUE } from '@kbn/slo-schema'; +import { EuiLoadingChart } from '@elastic/eui'; +import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; +import { useKibana } from '../../../utils/kibana_react'; +import { useFetchSloDetails } from '../../../hooks/slo/use_fetch_slo_details'; +import { paths } from '../../../../common/locators/paths'; + +import { EmbeddableSloProps } from './types'; + +export function SloOverview({ sloId, sloInstanceId, lastReloadRequestTime }: EmbeddableSloProps) { + const { + uiSettings, + application: { navigateToUrl }, + http: { basePath }, + } = useKibana().services; + const { isLoading, slo, refetch, isRefetching } = useFetchSloDetails({ + sloId, + instanceId: sloInstanceId, + }); + + useEffect(() => { + refetch(); + }, [lastReloadRequestTime, refetch]); + + const percentFormat = uiSettings.get('format:percent:defaultPattern'); + const isSloNotFound = !isLoading && slo === undefined; + + const getIcon = useCallback( + (type: string) => + ({ width = 20, height = 20, color }: { width: number; height: number; color: string }) => { + return ; + }, + [] + ); + + const sloSummary = slo?.summary; + const sloStatus = sloSummary?.status; + const healthyColor = useEuiBackgroundColor('success'); + const noDataColor = useEuiBackgroundColor('subdued'); + const degradingColor = useEuiBackgroundColor('warning'); + const violatedColor = useEuiBackgroundColor('danger'); + let color; + switch (sloStatus) { + case 'HEALTHY': + color = healthyColor; + break; + case 'NO_DATA': + color = noDataColor; + break; + case 'DEGRADING': + color = degradingColor; + break; + case 'VIOLATED': + color = violatedColor; + break; + default: + color = noDataColor; + } + + if (isRefetching || isLoading) { + return ( + + + + + + ); + } + + if (isSloNotFound) { + return ( + + + {i18n.translate('xpack.observability.sloEmbeddable.overview.sloNotFoundText', { + defaultMessage: + 'The SLO has been deleted. You can safely delete the widget from the dashboard.', + })} + + + ); + } + const TargetCopy = i18n.translate('xpack.observability.sloEmbeddable.overview.sloTargetLabel', { + defaultMessage: 'Target', + }); + const extraContent = `${TargetCopy} ${numeral(slo?.objective.target).format( + percentFormat + )}`; + // eslint-disable-next-line react/no-danger + const extra = ; + const metricData = + slo !== undefined + ? [ + { + color, + title: slo.name, + subtitle: slo.groupBy !== ALL_VALUE ? `${slo.groupBy}:${slo.instanceId}` : '', + icon: getIcon('visGauge'), + value: + sloStatus === 'NO_DATA' + ? NOT_AVAILABLE_LABEL + : numeral(slo.summary.sliValue).format(percentFormat), + valueFormatter: (value: number) => `${value}%`, + extra, + trend: [], + trendShape: MetricTrendShape.Area, + }, + ] + : []; + return ( + <> + + { + navigateToUrl( + basePath.prepend( + paths.observability.sloDetails( + slo!.id, + slo?.groupBy !== ALL_VALUE && slo?.instanceId ? slo.instanceId : undefined + ) + ) + ); + }} + /> + + + + ); +} + +export const LoadingContainer = euiStyled.div` + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; +`; + +export const LoadingContent = euiStyled.div` + flex: 0 0 auto; + align-self: center; + text-align: center; +`; diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_selector.tsx b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_selector.tsx new file mode 100644 index 0000000000000..468358127bd18 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_selector.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, { useEffect, useMemo, useState } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; +import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import { useFetchSloList } from '../../../hooks/slo/use_fetch_slo_list'; + +interface Props { + initialSlo?: SLOWithSummaryResponse; + onSelected: (slo: SLOWithSummaryResponse | undefined) => void; + hasError?: boolean; +} + +const SLO_REQUIRED = i18n.translate('xpack.observability.sloEmbeddable.config.errors.sloRequired', { + defaultMessage: 'SLO is required.', +}); + +export function SloSelector({ initialSlo, onSelected, hasError }: Props) { + const [options, setOptions] = useState>>([]); + const [selectedOptions, setSelectedOptions] = useState>>(); + const [searchValue, setSearchValue] = useState(''); + const { isInitialLoading, isLoading, sloList } = useFetchSloList({ + kqlQuery: `slo.name: ${searchValue.replaceAll(' ', '*')}*`, + }); + + useEffect(() => { + const isLoadedWithData = !isLoading && sloList!.results !== undefined; + const opts: Array> = isLoadedWithData + ? sloList!.results!.map((slo) => { + const label = + slo.instanceId !== ALL_VALUE + ? `${slo.name} (${slo.groupBy}: ${slo.instanceId})` + : slo.name; + return { + value: `${slo.id}-${slo.instanceId}`, + label, + instanceId: slo.instanceId, + }; + }) + : []; + setOptions(opts); + }, [isLoading, sloList]); + + const onChange = (opts: Array>) => { + setSelectedOptions(opts); + const selectedSlo = + opts.length === 1 + ? sloList!.results?.find((slo) => opts[0].value === `${slo.id}-${slo.instanceId}`) + : undefined; + + onSelected(selectedSlo); + }; + + const onSearchChange = useMemo( + () => + debounce((value: string) => { + setSearchValue(value); + }, 300), + [] + ); + + if (isInitialLoading) { + return null; + } + + return ( + + + + ); +} diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/types.ts b/x-pack/plugins/observability/public/embeddable/slo/overview/types.ts new file mode 100644 index 0000000000000..ea125ffa8a9d5 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EmbeddableInput } from '@kbn/embeddable-plugin/public'; + +export interface EmbeddableSloProps { + sloId: string | undefined; + sloInstanceId: string | undefined; + lastReloadRequestTime?: number | undefined; +} + +export type SloEmbeddableInput = EmbeddableInput & EmbeddableSloProps; diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index c40662219ddb7..d864a09fe6fdc 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -56,6 +56,7 @@ import { ObservabilityAIAssistantPluginSetup, ObservabilityAIAssistantPluginStart, } from '@kbn/observability-ai-assistant-plugin/public'; +import type { EmbeddableSetup } from '@kbn/embeddable-plugin/public'; import { AiopsPluginStart } from '@kbn/aiops-plugin/public/types'; import { RulesLocatorDefinition } from './locators/rules'; import { RuleDetailsLocatorDefinition } from './locators/rule_details'; @@ -111,6 +112,7 @@ export interface ObservabilityPublicPluginsSetup { triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; home?: HomePublicPluginSetup; usageCollection: UsageCollectionSetup; + embeddable: EmbeddableSetup; } export interface ObservabilityPublicPluginsStart { @@ -286,6 +288,14 @@ export class Plugin coreSetup.application.register(app); registerObservabilityRuleTypes(config, this.observabilityRuleTypeRegistry); + const registerSloEmbeddableFactory = async () => { + const { SloOverviewEmbeddableFactoryDefinition } = await import( + './embeddable/slo/overview/slo_embeddable_factory' + ); + const factory = new SloOverviewEmbeddableFactoryDefinition(coreSetup.getStartServices); + pluginsSetup.embeddable.registerEmbeddableFactory(factory.type, factory); + }; + registerSloEmbeddableFactory(); if (pluginsSetup.home) { pluginsSetup.home.featureCatalogue.registerSolution({ diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index 2b7a9e0749650..cfb16fea9a839 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -84,6 +84,8 @@ "@kbn/core-capabilities-common", "@kbn/observability-ai-assistant-plugin", "@kbn/osquery-plugin", + "@kbn/content-management-plugin", + "@kbn/embeddable-plugin", "@kbn/aiops-plugin", "@kbn/content-management-plugin", "@kbn/deeplinks-observability", diff --git a/x-pack/plugins/osquery/cypress.config.ts b/x-pack/plugins/osquery/cypress/cypress.config.ts similarity index 83% rename from x-pack/plugins/osquery/cypress.config.ts rename to x-pack/plugins/osquery/cypress/cypress.config.ts index 4efb4ce8c5429..26b1d9b67850d 100644 --- a/x-pack/plugins/osquery/cypress.config.ts +++ b/x-pack/plugins/osquery/cypress/cypress.config.ts @@ -11,11 +11,10 @@ import path from 'path'; import { safeLoad as loadYaml } from 'js-yaml'; import { readFileSync } from 'fs'; -import type { YamlRoleDefinitions } from '../../test_serverless/shared/lib'; -// eslint-disable-next-line @kbn/imports/no_boundary_crossing -import { setupUserDataLoader } from '../../test_serverless/functional/test_suites/security/cypress/support/setup_data_loader_tasks'; +import type { YamlRoleDefinitions } from '../../../test_serverless/shared/lib'; +import { setupUserDataLoader } from '../../../test_serverless/functional/test_suites/security/cypress/support/setup_data_loader_tasks'; const ROLES_YAML_FILE_PATH = path.join( - `${__dirname}/cypress/support`, + `${__dirname}/support`, 'project_controller_osquery_roles.yml' ); const roleDefinitions = loadYaml(readFileSync(ROLES_YAML_FILE_PATH, 'utf8')) as YamlRoleDefinitions; diff --git a/x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts index a84cdb5013047..ecad2eebf5248 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts @@ -198,7 +198,6 @@ describe('ALL - Add Integration', { tags: ['@ess', '@brokenInServerless'] }, () // test list of prebuilt queries navigateTo('/app/osquery/saved_queries'); - cy.waitForReact(); cy.react('EuiTableRow').should('have.length.above', 5); }); }); diff --git a/x-pack/plugins/osquery/cypress/e2e/all/packs_create_edit.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/packs_create_edit.cy.ts index 52d1236c1f12f..770d5afc5ec0f 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/packs_create_edit.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/packs_create_edit.cy.ts @@ -544,8 +544,6 @@ describe('Packs - Create and Edit', () => { recurse( () => { - cy.waitForReact(); - cy.getBySel('docsLoading').should('exist'); cy.getBySel('docsLoading').should('not.exist'); diff --git a/x-pack/plugins/osquery/cypress/e2e/roles/reader.cy.ts b/x-pack/plugins/osquery/cypress/e2e/roles/reader.cy.ts index 82b02bdda7289..67f7eff7f3ed4 100644 --- a/x-pack/plugins/osquery/cypress/e2e/roles/reader.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/roles/reader.cy.ts @@ -47,7 +47,6 @@ describe('Reader - only READ', { tags: ['@ess'] }, () => { it('should not be able to add nor run saved queries', () => { navigateTo('/app/osquery/saved_queries'); - cy.waitForReact(1000); cy.contains(savedQueryName); cy.contains('Add saved query').should('be.disabled'); cy.react('PlayButtonComponent', { @@ -70,13 +69,11 @@ describe('Reader - only READ', { tags: ['@ess'] }, () => { it('should not be able to enter live queries with just read and no run saved queries', () => { navigateTo('/app/osquery/live_queries/new'); - cy.waitForReact(1000); cy.contains('Permission denied'); }); it('should not be able to play in live queries history', () => { navigateTo('/app/osquery/live_queries'); - cy.waitForReact(1000); cy.contains('New live query').should('be.disabled'); cy.contains(liveQueryQuery); cy.react('EuiIconPlay', { options: { timeout: 3000 } }).should('not.exist'); @@ -85,7 +82,6 @@ describe('Reader - only READ', { tags: ['@ess'] }, () => { it('should not be able to add nor edit packs', () => { navigateTo('/app/osquery/packs'); - cy.waitForReact(1000); cy.contains('Add pack').should('be.disabled'); cy.getBySel('tablePaginationPopoverButton').click(); cy.getBySel('tablePagination-50-rows').click(); diff --git a/x-pack/plugins/osquery/cypress/e2e/roles/t1_and_t2_analyst.cy.ts b/x-pack/plugins/osquery/cypress/e2e/roles/t1_and_t2_analyst.cy.ts index 779df77f2d382..6528c9b911932 100644 --- a/x-pack/plugins/osquery/cypress/e2e/roles/t1_and_t2_analyst.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/roles/t1_and_t2_analyst.cy.ts @@ -57,7 +57,6 @@ describe(`T1 and T2 analysts`, { tags: ['@ess', '@serverless'] }, () => { it('should be able to run saved queries but not add new ones', () => { navigateTo('/app/osquery/saved_queries'); - cy.waitForReact(1000); cy.contains(savedQueryName); cy.contains('Add saved query').should('be.disabled'); cy.react('PlayButtonComponent', { @@ -79,7 +78,6 @@ describe(`T1 and T2 analysts`, { tags: ['@ess', '@serverless'] }, () => { it('should be able to play in live queries history', () => { navigateTo('/app/osquery/live_queries'); - cy.waitForReact(1000); cy.contains('New live query').should('not.be.disabled'); cy.contains(liveQueryQuery); cy.wait(1000); @@ -91,7 +89,6 @@ describe(`T1 and T2 analysts`, { tags: ['@ess', '@serverless'] }, () => { it('should be able to use saved query in a new query', () => { navigateTo('/app/osquery/live_queries'); - cy.waitForReact(1000); cy.contains('New live query').should('not.be.disabled').click(); selectAllAgents(); getSavedQueriesDropdown().type(`${savedQueryName}{downArrow} {enter}`); @@ -102,7 +99,6 @@ describe(`T1 and T2 analysts`, { tags: ['@ess', '@serverless'] }, () => { it('should not be able to add nor edit packs', () => { navigateTo('/app/osquery/packs'); - cy.waitForReact(1000); cy.getBySel('tablePaginationPopoverButton').click(); cy.getBySel('tablePagination-50-rows').click(); cy.contains('Add pack').should('be.disabled'); diff --git a/x-pack/plugins/osquery/serverless_cypress.config.ts b/x-pack/plugins/osquery/cypress/serverless_cypress.config.ts similarity index 85% rename from x-pack/plugins/osquery/serverless_cypress.config.ts rename to x-pack/plugins/osquery/cypress/serverless_cypress.config.ts index 6b60dc076631f..fff0d4431df52 100644 --- a/x-pack/plugins/osquery/serverless_cypress.config.ts +++ b/x-pack/plugins/osquery/cypress/serverless_cypress.config.ts @@ -6,8 +6,7 @@ */ import { defineCypressConfig } from '@kbn/cypress-config'; -// eslint-disable-next-line @kbn/imports/no_boundary_crossing -import { setupUserDataLoader } from '../../test_serverless/functional/test_suites/security/cypress/support/setup_data_loader_tasks'; +import { setupUserDataLoader } from '../../../test_serverless/functional/test_suites/security/cypress/support/setup_data_loader_tasks'; // eslint-disable-next-line import/no-default-export export default defineCypressConfig({ diff --git a/x-pack/plugins/osquery/cypress/support/e2e.ts b/x-pack/plugins/osquery/cypress/support/e2e.ts index ed267eaff8ac6..760aeb80d3ee8 100644 --- a/x-pack/plugins/osquery/cypress/support/e2e.ts +++ b/x-pack/plugins/osquery/cypress/support/e2e.ts @@ -23,17 +23,17 @@ // *********************************************************** // force ESM in this module -import type { SecuritySolutionDescribeBlockFtrConfig } from '@kbn/security-solution-plugin/scripts/run_cypress/utils'; - export {}; -import 'cypress-react-selector'; +// @ts-expect-error ts(2306) module has some interesting ways of importing, see https://github.com/cypress-io/cypress/blob/0871b03c5b21711cd23056454da8f23dcaca4950/npm/grep/README.md#support-file import registerCypressGrep from '@cypress/grep'; +registerCypressGrep(); -import { login } from '../../../../test_serverless/functional/test_suites/security/cypress/tasks/login'; +import type { SecuritySolutionDescribeBlockFtrConfig } from '@kbn/security-solution-plugin/scripts/run_cypress/utils'; import type { ServerlessRoleName } from './roles'; -registerCypressGrep(); +import 'cypress-react-selector'; +import { login } from '../../../../test_serverless/functional/test_suites/security/cypress/tasks/login'; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace diff --git a/x-pack/plugins/osquery/cypress/tasks/navigation.ts b/x-pack/plugins/osquery/cypress/tasks/navigation.ts index 72fe34c7bce42..13ab8d4105b3e 100644 --- a/x-pack/plugins/osquery/cypress/tasks/navigation.ts +++ b/x-pack/plugins/osquery/cypress/tasks/navigation.ts @@ -21,7 +21,11 @@ export const navigateTo = (page: string, opts?: Partial) = // There's a security warning toast that seemingly makes ui elements in the bottom right unavailable, so we close it closeToastIfVisible(); - cy.waitForReact(); + cy.waitForReact( + 10000, + Cypress.env('cypress-react-selector')?.root, + '../../../node_modules/resq/dist/index.js' + ); }; export const openNavigationFlyout = () => { diff --git a/x-pack/plugins/osquery/cypress/tsconfig.json b/x-pack/plugins/osquery/cypress/tsconfig.json index cb468e0fb8893..ddd53a1ad7156 100644 --- a/x-pack/plugins/osquery/cypress/tsconfig.json +++ b/x-pack/plugins/osquery/cypress/tsconfig.json @@ -2,12 +2,13 @@ "extends": "../../../../tsconfig.base.json", "include": [ "**/*", - "../cypress.config.ts", - "../serverless_cypress.config.ts", + "./cypress.config.ts", + "./serverless_cypress.config.ts", "../../../test_serverless/shared/lib" ], "exclude": [ - "target/**/*" + "target/**/*", + "../../../test_serverless/shared/lib/security/default_http_headers.ts" ], "compilerOptions": { "outDir": "target/types", diff --git a/x-pack/plugins/osquery/package.json b/x-pack/plugins/osquery/package.json index 32db6010c6573..e9ab128dd45fb 100644 --- a/x-pack/plugins/osquery/package.json +++ b/x-pack/plugins/osquery/package.json @@ -7,10 +7,10 @@ "scripts": { "cypress:burn": "yarn cypress:run --env burn=2 --headed", "cypress:changed-specs-only": "yarn cypress:run --changed-specs-only --env burn=2", - "cypress": "NODE_OPTIONS=--openssl-legacy-provider node ../security_solution/scripts/start_cypress_parallel --config-file ../osquery/cypress.config.ts --ftr-config-file ../../../x-pack/test/osquery_cypress/cli_config", + "cypress": "NODE_OPTIONS=--openssl-legacy-provider node ../security_solution/scripts/start_cypress_parallel --config-file ../osquery/cypress/cypress.config.ts --ftr-config-file ../../../x-pack/test/osquery_cypress/cli_config", "cypress:open": "yarn cypress open", "cypress:run": "yarn cypress run", - "cypress:serverless": "NODE_OPTIONS=--openssl-legacy-provider node ../security_solution/scripts/start_cypress_parallel --config-file ../osquery/serverless_cypress.config.ts --ftr-config-file ../../../x-pack/test/osquery_cypress/serverless_cli_config", + "cypress:serverless": "NODE_OPTIONS=--openssl-legacy-provider node ../security_solution/scripts/start_cypress_parallel --config-file ../osquery/cypress/serverless_cypress.config.ts --ftr-config-file ../../../x-pack/test/osquery_cypress/serverless_cli_config", "cypress:serverless:open": "yarn cypress:serverless open", "cypress:serverless:run": "yarn cypress:serverless run", "nyc": "../../../node_modules/.bin/nyc report --reporter=text-summary", diff --git a/x-pack/plugins/osquery/tsconfig.json b/x-pack/plugins/osquery/tsconfig.json index d2344a2581df8..6516c4241f0df 100644 --- a/x-pack/plugins/osquery/tsconfig.json +++ b/x-pack/plugins/osquery/tsconfig.json @@ -1,12 +1,10 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "outDir": "target/types", + "outDir": "target/types" }, "exclude": [ - "cypress.config.ts", - "serverless_cypress.config.ts", - "target/**/*", + "target/**/*" ], "include": [ // add all the folders contains files to be compiled @@ -15,8 +13,6 @@ "scripts/**/*", "scripts/**/**.json", "server/**/*", - "cypress.config.ts", - "serverless_cypress.config.ts", "../../../typings/**/*", // ECS and Osquery schema files "public/common/schemas/*/**.json", @@ -77,6 +73,6 @@ "@kbn/core-saved-objects-server", "@kbn/monaco", "@kbn/io-ts-utils", - "@kbn/shared-ux-page-kibana-template", + "@kbn/shared-ux-page-kibana-template" ] } diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx index ed4b73c3ea0d3..9f270771b3cc7 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx @@ -6,17 +6,22 @@ */ import React, { FC, useState, useCallback } from 'react'; +import { first, lastValueFrom } from 'rxjs'; +import { i18n } from '@kbn/i18n'; +import type { NotificationsStart } from '@kbn/core/public'; + import { ITagsClient, Tag, TagAttributes } from '../../../common/types'; -import { TagValidation } from '../../../common/validation'; import { isServerValidationError } from '../../services/tags'; import { getRandomColor, validateTag } from './utils'; import { CreateOrEditModal } from './create_or_edit_modal'; +import { useValidation } from './use_validation'; interface CreateTagModalProps { defaultValues?: Partial; onClose: () => void; onSave: (tag: Tag) => void; tagClient: ITagsClient; + notifications: NotificationsStart; } const getDefaultAttributes = (providedDefaults?: Partial): TagAttributes => ({ @@ -26,22 +31,21 @@ const getDefaultAttributes = (providedDefaults?: Partial): TagAtt ...providedDefaults, }); -const initialValidation: TagValidation = { - valid: true, - warnings: [], - errors: {}, -}; - export const CreateTagModal: FC = ({ defaultValues, tagClient, + notifications, onClose, onSave, }) => { - const [validation, setValidation] = useState(initialValidation); const [tagAttributes, setTagAttributes] = useState( getDefaultAttributes(defaultValues) ); + const { validation, setValidation, onNameChange, validation$, isValidating } = useValidation({ + tagAttributes, + tagClient, + validateDuplicateNameOnMount: true, + }); const setField = useCallback( (field: T) => @@ -55,6 +59,14 @@ export const CreateTagModal: FC = ({ ); const onSubmit = useCallback(async () => { + const { hasDuplicateNameError } = await lastValueFrom( + validation$.pipe(first((v) => v.isValidating === false)) + ); + + if (hasDuplicateNameError) { + return; + } + const clientValidation = validateTag(tagAttributes); setValidation(clientValidation); if (!clientValidation.valid) { @@ -68,18 +80,27 @@ export const CreateTagModal: FC = ({ // if e is IHttpFetchError, actual server error payload is in e.body if (isServerValidationError(e.body)) { setValidation(e.body.attributes); + } else { + notifications.toasts.addDanger({ + title: i18n.translate('xpack.savedObjectsTagging.saveTagErrorTitle', { + defaultMessage: 'An error occurred creating tag', + }), + text: e.body.message, + }); } } - }, [tagAttributes, tagClient, onSave]); + }, [validation$, tagAttributes, setValidation, tagClient, onSave, notifications.toasts]); return ( ); }; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx index f972220843b47..0b5e838213959 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC, useState, useCallback, useMemo } from 'react'; +import React, { FC, useState, useCallback, useMemo, useRef } from 'react'; import { EuiButtonEmpty, EuiButton, @@ -27,6 +27,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import useDebounce from 'react-use/lib/useDebounce'; import { TagAttributes, TagValidation, @@ -40,16 +41,23 @@ import { getRandomColor, useIfMounted } from './utils'; interface CreateOrEditModalProps { onClose: () => void; onSubmit: () => Promise; + onNameChange: ( + name: string, + options?: { debounced?: boolean; hasBeenModified?: boolean } + ) => Promise; mode: 'create' | 'edit'; tag: TagAttributes; validation: TagValidation; + isValidating: boolean; setField: (field: T) => (value: TagAttributes[T]) => void; } export const CreateOrEditModal: FC = ({ onClose, onSubmit, + onNameChange, validation, + isValidating, setField, tag, mode, @@ -57,6 +65,7 @@ export const CreateOrEditModal: FC = ({ const optionalMessageId = htmlIdGenerator()(); const ifMounted = useIfMounted(); const [submitting, setSubmitting] = useState(false); + const lastNameValue = useRef(tag.name); // we don't want this value to change when the user edits the tag // eslint-disable-next-line react-hooks/exhaustive-deps @@ -68,6 +77,8 @@ export const CreateOrEditModal: FC = ({ tag.description !== initialTag.description, [initialTag, tag] ); + const nameHasBeenModified = tag.name !== lastNameValue.current; + const setName = useMemo(() => setField('name'), [setField]); const setColor = useMemo(() => setField('color'), [setField]); const setDescription = useMemo(() => setField('description'), [setField]); @@ -91,6 +102,15 @@ export const CreateOrEditModal: FC = ({ }); }, [ifMounted, onSubmit]); + useDebounce( + () => { + onNameChange(tag.name, { debounced: true, hasBeenModified: nameHasBeenModified }); + lastNameValue.current = tag.name; + }, + 300, + [tag.name, nameHasBeenModified] + ); + return ( @@ -130,6 +150,7 @@ export const CreateOrEditModal: FC = ({ maxLength={tagNameMaxLength} value={tag.name} onChange={(e) => setName(e.target.value)} + isLoading={isValidating} data-test-subj="createModalField-name" /> @@ -238,6 +259,7 @@ export const CreateOrEditModal: FC = ({ fill data-test-subj="createModalConfirmButton" onClick={onFormSubmit} + isLoading={submitting} isDisabled={submitting || (isEdit && !tagHasBeenModified)} > {isEdit ? ( diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx index 4f0e956e6561c..d3419e831c18a 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx @@ -6,33 +6,40 @@ */ import React, { FC, useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { NotificationsStart } from '@kbn/core/public'; +import { first, lastValueFrom } from 'rxjs'; import { ITagsClient, Tag, TagAttributes } from '../../../common/types'; -import { TagValidation } from '../../../common/validation'; import { isServerValidationError } from '../../services/tags'; import { CreateOrEditModal } from './create_or_edit_modal'; import { validateTag } from './utils'; +import { useValidation } from './use_validation'; interface EditTagModalProps { tag: Tag; onClose: () => void; onSave: (tag: Tag) => void; tagClient: ITagsClient; + notifications: NotificationsStart; } -const initialValidation: TagValidation = { - valid: true, - warnings: [], - errors: {}, -}; - const getAttributes = (tag: Tag): TagAttributes => { const { id, ...attributes } = tag; return attributes; }; -export const EditTagModal: FC = ({ tag, onSave, onClose, tagClient }) => { - const [validation, setValidation] = useState(initialValidation); +export const EditTagModal: FC = ({ + tag, + onSave, + onClose, + tagClient, + notifications, +}) => { const [tagAttributes, setTagAttributes] = useState(getAttributes(tag)); + const { validation, setValidation, onNameChange, isValidating, validation$ } = useValidation({ + tagAttributes, + tagClient, + }); const setField = useCallback( (field: T) => @@ -46,8 +53,17 @@ export const EditTagModal: FC = ({ tag, onSave, onClose, tagC ); const onSubmit = useCallback(async () => { + const { hasDuplicateNameError } = await lastValueFrom( + validation$.pipe(first((v) => v.isValidating === false)) + ); + + if (hasDuplicateNameError) { + return; + } + const clientValidation = validateTag(tagAttributes); setValidation(clientValidation); + if (!clientValidation.valid) { return; } @@ -59,18 +75,27 @@ export const EditTagModal: FC = ({ tag, onSave, onClose, tagC // if e is IHttpFetchError, actual server error payload is in e.body if (isServerValidationError(e.body)) { setValidation(e.body.attributes); + } else { + notifications.toasts.addDanger({ + title: i18n.translate('xpack.savedObjectsTagging.editTagErrorTitle', { + defaultMessage: 'An error occurred editing tag', + }), + text: e.body.message, + }); } } - }, [tagAttributes, tagClient, onSave, tag]); + }, [validation$, tagAttributes, setValidation, tagClient, tag.id, onSave, notifications.toasts]); return ( ); }; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx index 66ec3139a89f9..9fb8dc28c466e 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx @@ -7,13 +7,19 @@ import React from 'react'; import { EuiDelayRender, EuiLoadingSpinner } from '@elastic/eui'; -import { OverlayStart, OverlayRef, ThemeServiceStart } from '@kbn/core/public'; +import type { + OverlayStart, + OverlayRef, + ThemeServiceStart, + NotificationsStart, +} from '@kbn/core/public'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { Tag, TagAttributes } from '../../../common/types'; import { ITagInternalClient } from '../../services'; interface GetModalOpenerOptions { overlays: OverlayStart; + notifications: NotificationsStart; theme: ThemeServiceStart; tagClient: ITagInternalClient; } @@ -40,7 +46,7 @@ const LazyEditTagModal = React.lazy(() => ); export const getCreateModalOpener = - ({ overlays, theme, tagClient }: GetModalOpenerOptions): CreateModalOpener => + ({ overlays, theme, tagClient, notifications }: GetModalOpenerOptions): CreateModalOpener => async ({ onCreate, defaultValues }: OpenCreateModalOptions) => { const modal = overlays.openModal( toMountPoint( @@ -55,6 +61,7 @@ export const getCreateModalOpener = onCreate(tag); }} tagClient={tagClient} + notifications={notifications} /> , { theme$: theme.theme$ } @@ -69,7 +76,7 @@ interface OpenEditModalOptions { } export const getEditModalOpener = - ({ overlays, theme, tagClient }: GetModalOpenerOptions) => + ({ overlays, theme, tagClient, notifications }: GetModalOpenerOptions) => async ({ tagId, onUpdate }: OpenEditModalOptions) => { const tag = await tagClient.get(tagId); @@ -86,6 +93,7 @@ export const getEditModalOpener = onUpdate(saved); }} tagClient={tagClient} + notifications={notifications} /> , { theme$: theme.theme$ } diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/use_validation.ts b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/use_validation.ts new file mode 100644 index 0000000000000..0d386e7aaa5a0 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/use_validation.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { BehaviorSubject } from 'rxjs'; +import useObservable from 'react-use/lib/useObservable'; + +import { type TagValidation, validateTagName } from '../../../common'; +import type { ITagsClient, TagAttributes } from '../../../common/types'; +import { duplicateTagNameErrorMessage, validateTag } from './utils'; + +const initialValidation: TagValidation = { + valid: true, + warnings: [], + errors: {}, +}; + +export const useValidation = ({ + tagAttributes, + tagClient, + validateDuplicateNameOnMount = false, +}: { + tagAttributes: TagAttributes; + tagClient: ITagsClient; + validateDuplicateNameOnMount?: boolean; +}) => { + const isMounted = useRef(false); + const [validation, setValidation] = useState(initialValidation); + const { + errors: { name: nameError }, + } = validation; + + const validation$ = useMemo( + () => + new BehaviorSubject({ + isValidating: false, + hasDuplicateNameError: false, + }), + [] + ); + + const { isValidating = false } = useObservable(validation$) ?? {}; + + const setIsValidating = useCallback( + (value: boolean) => { + validation$.next({ + ...validation$.value, + isValidating: value, + }); + }, + [validation$] + ); + + const validateDuplicateTagName = useCallback( + async (name: string) => { + const error = validateTagName(name); + if (error) { + return; + } + + const existingTag = await tagClient.findByName(name, { exact: true }); + + if (existingTag) { + setValidation((prev) => ({ + ...prev, + valid: false, + errors: { + ...prev.errors, + name: duplicateTagNameErrorMessage, + }, + })); + } + + setIsValidating(false); + }, + [tagClient, setIsValidating] + ); + + const onNameChange = useCallback( + async ( + name: string, + { + debounced = false, + hasBeenModified = true, + }: { debounced?: boolean; hasBeenModified?: boolean } = {} + ) => { + setIsValidating(true); + + if (debounced) { + if (hasBeenModified) { + await validateDuplicateTagName(name); + } + setIsValidating(false); + } + }, + [setIsValidating, validateDuplicateTagName] + ); + + useEffect(() => { + if (isMounted.current) { + onNameChange(tagAttributes.name); + } + }, [onNameChange, tagAttributes.name]); + + useEffect(() => { + if (isMounted.current) { + setValidation(validateTag(tagAttributes)); + } + }, [tagAttributes]); + + useEffect(() => { + if (validateDuplicateNameOnMount && tagAttributes.name && !isMounted.current) { + setIsValidating(true); + validateDuplicateTagName(tagAttributes.name); + } + isMounted.current = true; + }, [ + validateDuplicateNameOnMount, + tagAttributes.name, + validateDuplicateTagName, + validation$, + setIsValidating, + ]); + + useEffect(() => { + validation$.next({ + ...validation$.value, + hasDuplicateNameError: nameError === duplicateTagNameErrorMessage, + }); + }, [nameError, validation$]); + + return { + validation, + setValidation, + isValidating, + validation$, + onNameChange, + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/utils.ts b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/utils.ts index 52258be0600f3..62ae1023c5b84 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/utils.ts +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/utils.ts @@ -6,6 +6,8 @@ */ import { useCallback, useEffect, useRef } from 'react'; +import { i18n } from '@kbn/i18n'; + import { TagAttributes, TagValidation, @@ -21,6 +23,13 @@ export const getRandomColor = (): string => { return '#' + String(Math.floor(Math.random() * 16777215).toString(16)).padStart(6, '0'); }; +export const duplicateTagNameErrorMessage = i18n.translate( + 'xpack.savedObjectsTagging.validation.name.duplicateError', + { + defaultMessage: 'Name has already been taken.', + } +); + export const validateTag = (tag: TagAttributes): TagValidation => { const validation: TagValidation = { valid: true, diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/edit.ts b/x-pack/plugins/saved_objects_tagging/public/management/actions/edit.ts index 57955c05a2ac4..f0b8e1ed5ec0d 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/actions/edit.ts +++ b/x-pack/plugins/saved_objects_tagging/public/management/actions/edit.ts @@ -27,7 +27,7 @@ export const getEditAction = ({ tagClient, fetchTags, }: GetEditActionOptions): TagAction => { - const editModalOpener = getEditModalOpener({ overlays, theme, tagClient }); + const editModalOpener = getEditModalOpener({ overlays, theme, tagClient, notifications }); return { id: 'edit', name: ({ name }) => diff --git a/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx b/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx index 3bad8d82b664c..8093d59a29bd2 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx @@ -75,8 +75,8 @@ export const TagManagementPage: FC = ({ }); const createModalOpener = useMemo( - () => getCreateModalOpener({ overlays, theme, tagClient }), - [overlays, theme, tagClient] + () => getCreateModalOpener({ overlays, theme, tagClient, notifications }), + [overlays, theme, tagClient, notifications] ); const tableActions = useMemo(() => { diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.ts index 5c93917f476c5..e5ad1dfa5095f 100644 --- a/x-pack/plugins/saved_objects_tagging/public/plugin.ts +++ b/x-pack/plugins/saved_objects_tagging/public/plugin.ts @@ -67,7 +67,7 @@ export class SavedObjectTaggingPlugin return {}; } - public start({ http, application, overlays, theme, analytics }: CoreStart) { + public start({ http, application, overlays, theme, analytics, notifications }: CoreStart) { this.tagCache = new TagsCache({ refreshHandler: () => this.tagClient!.getAll({ asSystemRequest: true }), refreshInterval: this.config.cacheRefreshInterval, @@ -92,6 +92,7 @@ export class SavedObjectTaggingPlugin capabilities: getTagsCapabilities(application.capabilities), overlays, theme, + notifications, }), }; } diff --git a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.mock.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.mock.ts index d7cc52b857dc0..0183be7fc9d21 100644 --- a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.mock.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.mock.ts @@ -15,6 +15,7 @@ const createInternalClientMock = () => { delete: jest.fn(), update: jest.fn(), find: jest.fn(), + findByName: jest.fn(), bulkDelete: jest.fn(), }; diff --git a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts index 46aa43df7d075..224c658632523 100644 --- a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts @@ -173,6 +173,15 @@ export class TagsClient implements ITagInternalClient { return response; } + public async findByName(name: string, { exact }: { exact?: boolean } = { exact: false }) { + const { tags = [] } = await this.find({ page: 1, perPage: 10000, search: name }); + if (exact) { + const tag = tags.find((t) => t.name.toLocaleLowerCase() === name.toLocaleLowerCase()); + return tag ?? null; + } + return tags.length > 0 ? tags[0] : null; + } + public async bulkDelete(tagIds: string[]) { const startTime = window.performance.now(); await this.http.post<{}>('/internal/saved_objects_tagging/tags/_bulk_delete', { diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts index 36f4f61f4d582..e6a504c49e87d 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { OverlayStart, ThemeServiceStart } from '@kbn/core/public'; +import { NotificationsStart, OverlayStart, ThemeServiceStart } from '@kbn/core/public'; import { SavedObjectsTaggingApiUiComponent } from '@kbn/saved-objects-tagging-oss-plugin/public'; import { TagsCapabilities } from '../../common'; import { ITagInternalClient, ITagsCache } from '../services'; @@ -22,6 +22,7 @@ export interface GetComponentsOptions { overlays: OverlayStart; theme: ThemeServiceStart; tagClient: ITagInternalClient; + notifications: NotificationsStart; } export const getComponents = ({ @@ -30,8 +31,9 @@ export const getComponents = ({ overlays, theme, tagClient, + notifications, }: GetComponentsOptions): SavedObjectsTaggingApiUiComponent => { - const openCreateModal = getCreateModalOpener({ overlays, theme, tagClient }); + const openCreateModal = getCreateModalOpener({ overlays, theme, tagClient, notifications }); return { TagList: getConnectedTagListComponent({ cache }), TagSelector: getConnectedTagSelectorComponent({ cache, capabilities, openCreateModal }), diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts index 8d53135f3f55a..b2dca68d5cc95 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { OverlayStart, ThemeServiceStart } from '@kbn/core/public'; +import type { NotificationsStart, OverlayStart, ThemeServiceStart } from '@kbn/core/public'; import { SavedObjectsTaggingApiUi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import { TagsCapabilities } from '../../common'; import { ITagsCache, ITagInternalClient } from '../services'; @@ -29,6 +29,7 @@ interface GetUiApiOptions { capabilities: TagsCapabilities; cache: ITagsCache; client: ITagInternalClient; + notifications: NotificationsStart; } export const getUiApi = ({ @@ -37,8 +38,16 @@ export const getUiApi = ({ client, overlays, theme, + notifications, }: GetUiApiOptions): SavedObjectsTaggingApiUi => { - const components = getComponents({ cache, capabilities, overlays, theme, tagClient: client }); + const components = getComponents({ + cache, + capabilities, + overlays, + theme, + tagClient: client, + notifications, + }); const getTagList = buildGetTagList(cache); diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts b/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts index 32318f39b5a0c..8da060eda0cca 100644 --- a/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts +++ b/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts @@ -33,7 +33,7 @@ export const registerInternalFindTagsRoute = (router: TagsPluginRouter) => { perPage: query.perPage, search: query.search, type: [tagSavedObjectTypeName], - searchFields: ['title', 'description'], + searchFields: ['name', 'description'], }); const tags = findResponse.saved_objects.map(savedObjectToTag); diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/tags/create_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/tags/create_tag.ts index 7b9e6a32d3aea..0c48168eed281 100644 --- a/x-pack/plugins/saved_objects_tagging/server/routes/tags/create_tag.ts +++ b/x-pack/plugins/saved_objects_tagging/server/routes/tags/create_tag.ts @@ -24,6 +24,14 @@ export const registerCreateTagRoute = (router: TagsPluginRouter) => { router.handleLegacyErrors(async (ctx, req, res) => { try { const { tagsClient } = await ctx.tags; + + const existingTag = await tagsClient.findByName(req.body.name, { exact: true }); + if (existingTag) { + return res.conflict({ + body: `A tag with the name "${req.body.name}" already exists.`, + }); + } + const tag = await tagsClient.create(req.body); return res.ok({ body: { diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/tags/update_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/tags/update_tag.ts index 67b7d7a6acbda..62f8c73dddc78 100644 --- a/x-pack/plugins/saved_objects_tagging/server/routes/tags/update_tag.ts +++ b/x-pack/plugins/saved_objects_tagging/server/routes/tags/update_tag.ts @@ -28,6 +28,14 @@ export const registerUpdateTagRoute = (router: TagsPluginRouter) => { const { id } = req.params; try { const { tagsClient } = await ctx.tags; + + const existingTag = await tagsClient.findByName(req.body.name, { exact: true }); + if (existingTag && existingTag.id !== id) { + return res.conflict({ + body: `A tag with the name "${req.body.name}" already exists.`, + }); + } + const tag = await tagsClient.update(id, req.body); return res.ok({ body: { diff --git a/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.mock.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.mock.ts index e56bf2b963112..9507208971d84 100644 --- a/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.mock.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.mock.ts @@ -14,6 +14,7 @@ const createClientMock = () => { getAll: jest.fn(), delete: jest.fn(), update: jest.fn(), + findByName: jest.fn(), }; return mock; diff --git a/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.ts index 23e1da56d6143..f213f279975a3 100644 --- a/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.ts @@ -6,7 +6,7 @@ */ import { SavedObjectsClientContract } from '@kbn/core/server'; -import { CreateTagOptions } from '@kbn/saved-objects-tagging-oss-plugin/common/types'; +import { CreateTagOptions, Tag } from '@kbn/saved-objects-tagging-oss-plugin/common/types'; import { TagSavedObject, TagAttributes, ITagsClient } from '../../../common/types'; import { tagSavedObjectTypeName } from '../../../common/constants'; import { TagValidationError } from './errors'; @@ -63,6 +63,28 @@ export class TagsClient implements ITagsClient { return results.map(savedObjectToTag); } + public async findByName( + name: string, + { exact = false }: { exact?: boolean | undefined } = {} + ): Promise { + const response = await this.soClient.find({ + type: this.type, + search: name, + searchFields: ['name'], + perPage: 1000, + }); + + if (response.total === 0) { + return null; + } + + const tag = exact + ? response.saved_objects.find((t) => t.attributes.name.toLowerCase() === name.toLowerCase()) + : response.saved_objects[0]; + + return tag ? savedObjectToTag(tag) : null; + } + public async delete(id: string) { // `removeReferencesTo` security check is the same as a `delete` operation's, so we can use the scoped client here. // If that was to change, we would need to use the internal client instead. A FTR test is ensuring diff --git a/x-pack/plugins/security_solution/common/api/timeline/model/api.ts b/x-pack/plugins/security_solution/common/api/timeline/model/api.ts index 230cf122dcbf6..24a9a24f8fd8e 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/model/api.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/model/api.ts @@ -336,6 +336,7 @@ export const SavedTimelineRuntimeType = runtimeTypes.partial({ createdBy: unionWithNullType(runtimeTypes.string), updated: unionWithNullType(runtimeTypes.number), updatedBy: unionWithNullType(runtimeTypes.string), + savedSearchId: unionWithNullType(runtimeTypes.string), }); export type SavedTimeline = runtimeTypes.TypeOf; @@ -666,6 +667,7 @@ export interface TimelineResult { updated?: Maybe; updatedBy?: Maybe; version: string; + savedSearchId?: Maybe; } export interface ResponseTimeline { diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts index ec1f9840962da..6a4686a842afe 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts @@ -22,7 +22,7 @@ export const policyFactory = ( return { meta: { license, - license_uid: licenseUid, + license_uuid: licenseUid, cluster_uuid: clusterUuid, cluster_name: clusterName, cloud, diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts index 8b1480656bbb4..8e1c4e087c827 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts @@ -196,7 +196,7 @@ export const eventsOnlyPolicy = (): PolicyConfig => ({ meta: { license: '', cloud: false, - license_uid: '', + license_uuid: '', cluster_name: '', cluster_uuid: '', serverless: false, diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index e50fd5aa089ff..8f195b926e1f0 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -942,7 +942,7 @@ export interface PolicyConfig { meta: { license: string; cloud: boolean; - license_uid: string; + license_uuid: string; cluster_uuid: string; cluster_name: string; serverless: boolean; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts index b71b48e425258..49aea54bd74e6 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts @@ -133,6 +133,7 @@ export interface TimelineInput { savedQueryId?: Maybe; sort?: Maybe; status?: Maybe; + savedSearchId: Maybe; } export enum FlowDirection { diff --git a/x-pack/plugins/security_solution/common/types/timeline/saved_object.ts b/x-pack/plugins/security_solution/common/types/timeline/saved_object.ts index 782b1e8e529b8..767e7266ffed0 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/saved_object.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/saved_object.ts @@ -246,6 +246,7 @@ export const SavedObjectTimelineRuntimeType = runtimeTypes.partial({ createdBy: unionWithNullType(runtimeTypes.string), updated: unionWithNullType(runtimeTypes.number), updatedBy: unionWithNullType(runtimeTypes.string), + savedSearchId: unionWithNullType(runtimeTypes.string), }); type SavedObjectTimeline = runtimeTypes.TypeOf; diff --git a/x-pack/plugins/security_solution/common/types/timeline/store.ts b/x-pack/plugins/security_solution/common/types/timeline/store.ts index ceed44c45e03c..96be2a6e0a064 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/store.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/store.ts @@ -58,6 +58,8 @@ export interface TimelinePersistInput { templateTimelineId?: string | null; templateTimelineVersion?: number | null; title?: string; + /* used to saved discover Saved search Id */ + savedSearchId?: string | null; } /** Invoked when a column is sorted */ diff --git a/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management/coverage_overview_dashboard.md b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management/coverage_overview_dashboard.md new file mode 100644 index 0000000000000..08f23aceda9b9 --- /dev/null +++ b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management/coverage_overview_dashboard.md @@ -0,0 +1,202 @@ +# Coverage Overview Dashboard + +This is a test plan for the Mitre Att&ck coverage overview dashboard + +Status: `in progress`. The current test plan matches `Milestone 1 - MVP` of the [Protections/Detections Coverage Overview Page](https://github.com/elastic/security-team/issues/2905) epic. The plan will be built upon further as more feature are added in later milestones. + +## Useful information + +### Tickets + +- [Protections/Detections Coverage Overview Page](https://github.com/elastic/security-team/issues/2905) epic +- [Add test coverage for Protections/Detections Coverage Overview](https://github.com/elastic/kibana/issues/162250) +- [Write a test plan for Protections/Detections Coverage Overview](https://github.com/elastic/kibana/issues/162248) + +### Terminology + +- **MITRE ATT&CK**: The [3rd party framework](https://attack.mitre.org/) the dashboard is built upon. It is a knowledge base of attack tactics and techniques adversaries use in real world applications. + +- **Tactic**: A generalized category or process that adversaries use to attack a system. Envelops many relevant Mitre Att&ck techniques + +- **Technique**: A specific technique adversaries use to attack a system. Can belong to one or more different Mitre Tactics and can potentially contain one or more sub-techniques further describing the process. + +- **Rule Activity**: The filter type defining rule status, current options are `enabled` and `disabled`. + +- **Rule Source**: The filter type defining rule type, current options are `prebuilt`(from elastic prebuilt rules package) and `custom`(created by user) + +-**Initial filter state**: The filters present on initial page load. Rule activity will be set to `enabled`, rule source will be set to `prebuilt` and `custom` simultaneously. + +-**Dashboard containing the rule data**: The normal render of the coverage overview dashboard. Any returned rule data mapped correctly to the tile layout of all the MITRE data in a colored grid + +### Assumptions + +- Currently all scenarios below only apply to rules that have correctly mapped `threat` fields (unmapped fields or `threat` fields that don't contain current versioned Mitre Att&ck data will not be displayed in the dashboard) +- The feature is available under the Basic license +- "Rules" will be referring to Security rules only (unless stated otherwise) +- Page always loads with initial filter state + +### Non-functional requirements + +- Number of rules needs to be under 10k due to [an issue](https://github.com/elastic/kibana/issues/160698) + +## Scenarios + +### Coverage overview workflow: base cases + +#### **Scenario: No rules installed** + +**Automation**: 1 e2e test + 1 unit test + 1 integration test. + +```Gherkin +Given no rules installed/created +When user navigates to Coverage Overview page +Then an empty grid of all Mitre tactics and techniques is displayed +``` + +#### **Scenario: Rules installed** + +**Automation**: 1 e2e test + 2 integration test + +```Gherkin +Given prebuilt rules installed and/or custom rules created +And rules enabled +When user navigates to Coverage Overview page +Then page should render all rule data in grid +And color tiles according to filters and dashboard legend + +CASE: Test case should work with non-security rules both present and not present in system +``` + +#### **Scenario: User clicks on tile** + +**Automation**: 1 e2e test + 1 unit test. + +```Gherkin +Given prebuilt rules installed and/or custom rules created +And rules enabled +When user navigates to Coverage Overview page +And clicks on technique tile with non zero rules +Then the popover should display the same number of rule names under their corresponding rule activity section +And each name should link to its own rule details page +And popover title should link to corresponding MITRE technique definition +``` + +#### **Scenario: User clicks on expand/collapse cells button** + +**Automation**: 1 unit test. + +```Gherkin +Given prebuilt rules installed and/or custom rules created +And rules enabled +When user navigates to Coverage Overview page +And clicks on expand cells +Then the grid should display expanded cell view for each tile +And each tile should contain the correct number for enabled/disabled rule count display +``` + +#### **Scenario: User updates from 7.x to 8.x** + +**Automation**: Manual testing. + +```Gherkin +Given user is on `7.x` version of kibana +And has prebuilt rules installed and/or custom rules created +When user upgrades to `8.x` version of kibana +And navigates to the coverage overview page +Then no errors should be thrown when displaying the dashboard containing the rule data +``` + +### Coverage overview workflow: filters + +#### **Scenario: No filters are present** + +**Automation**: 1 integration test. + +```Gherkin +Given coverage overview page is loaded with rule data +When no filters or search term are present +Then the dashboard is rendered according to the rule data +``` + +#### **Scenario: Users enables filters** + +**Automation**: integration tests + e2e tests. + +```Gherkin +Given coverage overview page is loaded with rule data +When filter(s) is/are enabled +Then all filtered rule data is fetched and dashboard containing the rule data is rendered + +CASE: Filtering should work for all permutations of activity and source filters + +Examples: + | type | + | enabled | + | disabled | + | prebuilt | + | custom | + | enabled and disabled | + | prebuilt and custom | + | all | +``` + +#### **Scenario: Search term filter present** + +**Automation**: 1 integration test + 1 e2e test. + +```Gherkin +Given coverage overview page is loaded with rule data +When search term filter is present in search box +And user submits the search +Then only search-filtered rule data is fetched and rendered +``` + +### Coverage overview workflow: rule enabling + +#### **Scenario: User can't enable all rules when no disabled rules** + +**Automation**: 1 unit test. + +```Gherkin +Given coverage overview page is loaded with rule data +When user clicks on a technique tile with no disabled rules +Then "enable all disabled" button should be disabled +``` + +#### **Scenario: User enables all rules for technique** + +**Automation**: 1 e2e test. + +```Gherkin +Given coverage overview page is loaded with rule data +When user clicks on a technique tile with X disabled rules +And clicks "enable all disabled" button +Then all X disabled rules hould be enabled +And user should see success toast message for X rules enabled +And page should update data +``` + +#### **Scenario: User can't enable rules when they don't have CRUD privileges** + +**Automation**: 1 unit test. + +```Gherkin +Given coverage overview page is loaded with rule data +And user that doesn't have CRUD permissions +When user clicks on a technique tile with disabled rules +Then "enable all disabled" button should be disabled +``` + +### Error handling + +#### **Scenario: Error is handled when API error is returned** + +**Automation**: 2 e2e test. + +```Gherkin +Given a user navigates to coverage overview page +And any error is returned from coverage overview API +Then error is handled and displayed via a toast + +CASE: Should work for valid and invalid API body +``` diff --git a/x-pack/plugins/security_solution/kibana.jsonc b/x-pack/plugins/security_solution/kibana.jsonc index a2e7af427da08..bf7997fa64e6a 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -49,7 +49,9 @@ "dataViewEditor", "stackConnectors", "discover", - "notifications" + "notifications", + "savedObjects", + "savedSearch" ], "optionalPlugins": [ "cloudExperiments", diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index 88ced06445c07..4b9941b1cbe5d 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -31,6 +31,7 @@ import type { StartServices } from '../types'; import { PageRouter } from './routes'; import { UserPrivilegesProvider } from '../common/components/user_privileges/user_privileges_context'; import { ReactQueryClientProvider } from '../common/containers/query_client/query_client_provider'; +import { DiscoverInTimelineContextProvider } from '../common/components/discover_in_timeline/provider'; import { AssistantProvider } from '../assistant/provider'; interface StartAppComponent { @@ -77,13 +78,15 @@ const StartAppComponent: FC = ({ getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions} > - - {children} - + + + {children} + + diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/callout.test.tsx b/x-pack/plugins/security_solution/public/common/components/callouts/callout.test.tsx index 4ba576519287a..dd728ad6ba745 100644 --- a/x-pack/plugins/security_solution/public/common/components/callouts/callout.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/callouts/callout.test.tsx @@ -29,7 +29,7 @@ describe('callout', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); test('renders the callout data-test-subj from the given id', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/index.test.tsx index 915cac434f57b..ed262ee04d9db 100644 --- a/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/index.test.tsx @@ -37,7 +37,7 @@ describe('useChartSettingsPopoverConfiguration', () => { {children} ); - beforeEach(() => jest.resetAllMocks()); + beforeEach(() => jest.clearAllMocks()); test('it returns the expected defaultInitialPanelId', () => { const { result } = renderHook( diff --git a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/__mocks__/use_discover_in_timeline_actions.tsx b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/__mocks__/use_discover_in_timeline_actions.tsx new file mode 100644 index 0000000000000..cd192a528e36f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/__mocks__/use_discover_in_timeline_actions.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const useDiscoverInTimelineActions = () => { + return { + resetDiscoverAppState: jest.fn(), + restoreDiscoverAppStateFromSavedSearch: jest.fn(), + updateSavedSearch: jest.fn(), + getAppStateFromSavedSearch: jest.fn(), + defaultDiscoverAppState: { + query: { + query: '', + language: 'kuery', + }, + sort: [['@timestamp', 'desc']], + columns: [], + index: 'security-solution-default', + interval: 'auto', + filters: [], + hideChart: true, + grid: {}, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/context.tsx b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/context.tsx new file mode 100644 index 0000000000000..307293b0cfa36 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/context.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { DiscoverStateContainer } from '@kbn/discover-plugin/public'; +import type { RefObject } from 'react'; +import { createContext } from 'react'; +import type { useDiscoverInTimelineActions } from './use_discover_in_timeline_actions'; + +export interface DiscoverInTimelineContextType + extends ReturnType { + discoverStateContainer: RefObject; + setDiscoverStateContainer: (stateContainer: DiscoverStateContainer) => void; +} + +export const DiscoverInTimelineContext = createContext(null); diff --git a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/mocks/discover_in_timeline_provider.tsx b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/mocks/discover_in_timeline_provider.tsx new file mode 100644 index 0000000000000..e77e3ecd13dbd --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/mocks/discover_in_timeline_provider.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DiscoverStateContainer } from '@kbn/discover-plugin/public'; +import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks'; +import React, { useRef, useCallback } from 'react'; +import type { FC, PropsWithChildren } from 'react'; +import { DiscoverInTimelineContext } from '../context'; +import { useDiscoverInTimelineActions } from '../use_discover_in_timeline_actions'; + +type Props = PropsWithChildren<{}>; + +jest.mock('../use_discover_in_timeline_actions'); + +export const MockDiscoverInTimelineContext: FC = ({ children }) => { + const discoverStateContainer = useRef(discoverPluginMock.getDiscoverStateMock({})); + + const setDiscoverStateContainer = useCallback((stateContainer: DiscoverStateContainer) => { + discoverStateContainer.current = stateContainer; + }, []); + + const actions = useDiscoverInTimelineActions(discoverStateContainer); + + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/provider.tsx b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/provider.tsx new file mode 100644 index 0000000000000..abf03e6b4f466 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/provider.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DiscoverStateContainer } from '@kbn/discover-plugin/public'; +import type { PropsWithChildren, FC } from 'react'; +import React, { useCallback, useRef } from 'react'; +import { DiscoverInTimelineContext } from './context'; +import { useDiscoverInTimelineActions } from './use_discover_in_timeline_actions'; + +type DiscoverInTimelineContextProviderProps = PropsWithChildren<{}>; + +export const DiscoverInTimelineContextProvider: FC = ( + props +) => { + const discoverStateContainer = useRef(); + + const actions = useDiscoverInTimelineActions(discoverStateContainer); + + const setDiscoverStateContainer = useCallback((stateContainer: DiscoverStateContainer) => { + discoverStateContainer.current = stateContainer; + }, []); + + return ( + + {props.children} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/translations.ts b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/translations.ts new file mode 100644 index 0000000000000..06f5c01aad5dc --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const DISCOVER_SEARCH_SAVE_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.timelines.discoverInTimeline.save_saved_search_error', + { + defaultMessage: 'Error while saving the Discover search', + } +); + +export const DISCOVER_SEARCH_SAVE_ERROR_UNKNOWN = i18n.translate( + 'xpack.securitySolution.timelines.discoverInTimeline.save_saved_search_unknown_error', + { + defaultMessage: 'Unknown error occurred while saving Discover search', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.test.tsx b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.test.tsx new file mode 100644 index 0000000000000..79e138347c79c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.test.tsx @@ -0,0 +1,265 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; +import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks'; +import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import type { SavedSearch } from '@kbn/saved-search-plugin/common'; +import { renderHook } from '@testing-library/react-hooks'; +import { + createSecuritySolutionStorageMock, + kibanaObservable, + mockGlobalState, + SUB_PLUGINS_REDUCER, + TestProviders, +} from '../../mock'; +import { useDiscoverInTimelineActions } from './use_discover_in_timeline_actions'; +import type { Filter } from '@kbn/es-query'; +import { createStartServicesMock } from '../../lib/kibana/kibana_react.mock'; +import { useKibana } from '../../lib/kibana'; +import type { State } from '../../store'; +import { createStore } from '../../store'; +import { TimelineId } from '../../../../common/types'; +import type { ComponentType, FC, PropsWithChildren } from 'react'; +import React from 'react'; + +const mockDiscoverStateContainerRef = { + current: discoverPluginMock.getDiscoverStateMock({}), +}; + +jest.mock('../../lib/kibana'); +const mockState: State = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.active], + title: 'Active Timeline', + description: 'Active Timeline Description', + }, + }, + }, +}; + +jest.mock('./use_discover_in_timeline_actions', () => { + const actual = jest.requireActual('./use_discover_in_timeline_actions'); + return actual; +}); + +const { storage } = createSecuritySolutionStorageMock(); + +const getTestProviderWithCustomState = (state: State = mockState) => { + const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + const MockTestProvider: FC> = ({ children }) => ( + {children} + ); + + return MockTestProvider; +}; + +const renderTestHook = (customWrapper: ComponentType = getTestProviderWithCustomState()) => { + return renderHook(() => useDiscoverInTimelineActions(mockDiscoverStateContainerRef), { + wrapper: customWrapper, + }); +}; + +const customQuery = { + language: 'kuery', + query: '_id: *', +}; + +const customFilter = { + $state: { + store: 'appState', + }, + meta: { + alias: null, + disabled: false, + field: 'ecs.version', + index: 'kibana-event-log-data-view', + key: 'ecs.version', + negate: false, + params: { + query: '1.8.0', + }, + type: 'phrase', + }, + query: { + match_phrase: { + 'ecs.version': '1.8.0', + }, + }, +} as Filter; + +const originalSavedSearchMock = { + id: 'the-saved-search-id', + title: 'A saved search', + breakdownField: 'customBreakDownField', + searchSource: createSearchSourceMock({ + index: dataViewMock, + filter: [customFilter], + query: customQuery, + }), +}; + +export const savedSearchMock = { + ...originalSavedSearchMock, + hideChart: true, + sort: ['@timestamp', 'desc'], + timeRange: { + from: 'now-20d', + to: 'now', + }, +} as unknown as SavedSearch; + +const startServicesMock = createStartServicesMock(); + +describe('useDiscoverInTimelineActions', () => { + beforeEach(() => { + (useKibana as jest.Mock).mockImplementation(() => ({ + services: startServicesMock, + })); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('getAppStateFromSavedSearch', () => { + it('should reach out to discover to convert app state from saved search', async () => { + const { result, waitFor } = renderTestHook(); + const { appState } = result.current.getAppStateFromSavedSearch(savedSearchMock); + await waitFor(() => { + expect(appState).toMatchObject( + expect.objectContaining({ + breakdownField: 'customBreakDownField', + columns: ['default_column'], + filters: [customFilter], + grid: undefined, + hideAggregatedPreview: undefined, + hideChart: true, + index: 'the-data-view-id', + interval: 'auto', + query: customQuery, + rowHeight: undefined, + rowsPerPage: undefined, + savedQuery: undefined, + sort: [['@timestamp', 'desc']], + viewMode: undefined, + }) + ); + }); + }); + }); + + describe('restoreDiscoverAppStateFromSavedSearch', () => { + it('should restore basic discover app state and timeRange from a given saved Search', async () => { + const { result, waitFor } = renderTestHook(); + result.current.restoreDiscoverAppStateFromSavedSearch(savedSearchMock); + + await waitFor(() => { + const appState = mockDiscoverStateContainerRef.current.appState.getState(); + const globalState = mockDiscoverStateContainerRef.current.globalState.get(); + expect(appState).toMatchObject({ + breakdownField: 'customBreakDownField', + columns: ['default_column'], + filters: [customFilter], + grid: undefined, + hideAggregatedPreview: undefined, + hideChart: true, + index: 'the-data-view-id', + interval: 'auto', + query: customQuery, + rowHeight: undefined, + rowsPerPage: undefined, + savedQuery: undefined, + sort: [['@timestamp', 'desc']], + viewMode: undefined, + }); + + expect(globalState).toMatchObject({ time: { from: 'now-20d', to: 'now' } }); + }); + }); + }); + describe('resetDiscoverAppState', () => { + it('should reset Discover AppState to a default state', async () => { + const { result, waitFor } = renderTestHook(); + result.current.resetDiscoverAppState(); + await waitFor(() => { + const appState = mockDiscoverStateContainerRef.current.appState.getState(); + expect(appState).toMatchObject(result.current.defaultDiscoverAppState); + }); + }); + it('should reset Discover time to a default state', async () => { + const { result, waitFor } = renderTestHook(); + result.current.resetDiscoverAppState(); + await waitFor(() => { + const globalState = mockDiscoverStateContainerRef.current.globalState.get(); + expect(globalState).toMatchObject({ time: { from: 'now-15m', to: 'now' } }); + }); + }); + }); + describe('updateSavedSearch', () => { + it('should add defaults to the savedSearch before updating saved search', async () => { + const { result } = renderTestHook(); + await result.current.updateSavedSearch(savedSearchMock, TimelineId.active); + + expect(startServicesMock.savedSearch.save).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + timeRestore: true, + timeRange: { + from: 'now-20d', + to: 'now', + }, + tags: ['security-solution-default'], + }), + expect.objectContaining({ + copyOnSave: true, + }) + ); + }); + it('should send update request when savedSearchId is already available', async () => { + const localMockState: State = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.active], + title: 'Active Timeline', + description: 'Active Timeline Description', + savedSearchId: 'saved_search_id', + }, + }, + }, + }; + + const LocalTestProvider = getTestProviderWithCustomState(localMockState); + const { result } = renderTestHook(LocalTestProvider); + await result.current.updateSavedSearch(savedSearchMock, TimelineId.active); + + expect(startServicesMock.savedSearch.save).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + timeRestore: true, + timeRange: { + from: 'now-20d', + to: 'now', + }, + tags: ['security-solution-default'], + id: 'saved_search_id', + }), + expect.objectContaining({ + copyOnSave: false, + }) + ); + }); + it('should raise appropriate notification in case of any error in saving discover saved search', () => {}); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx new file mode 100644 index 0000000000000..8361612231a24 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { DiscoverStateContainer } from '@kbn/discover-plugin/public'; +import type { SaveSavedSearchOptions } from '@kbn/saved-search-plugin/public'; +import type { RefObject } from 'react'; +import { useMemo, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import type { SavedSearch } from '@kbn/saved-search-plugin/common'; +import type { DiscoverAppState } from '@kbn/discover-plugin/public/application/main/services/discover_app_state_container'; +import type { TimeRange } from '@kbn/es-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { endTimelineSaving, startTimelineSaving } from '../../../timelines/store/timeline/actions'; +import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; +import { TimelineId } from '../../../../common/types'; +import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline'; +import { useAppToasts } from '../../hooks/use_app_toasts'; +import { useShallowEqualSelector } from '../../hooks/use_selector'; +import { useKibana } from '../../lib/kibana'; +import { useSourcererDataView } from '../../containers/sourcerer'; +import { SourcererScopeName } from '../../store/sourcerer/model'; +import { + DISCOVER_SEARCH_SAVE_ERROR_TITLE, + DISCOVER_SEARCH_SAVE_ERROR_UNKNOWN, +} from './translations'; + +export const defaultDiscoverTimeRange: TimeRange = { + from: 'now-15m', + to: 'now', + mode: 'relative', +}; + +export const useDiscoverInTimelineActions = ( + discoverStateContainer: RefObject +) => { + const { addError } = useAppToasts(); + + const { + services: { customDataService: discoverDataService, savedSearch: savedSearchService }, + } = useKibana(); + + const dispatch = useDispatch(); + + const { dataViewId } = useSourcererDataView(SourcererScopeName.detections); + + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const timeline = useShallowEqualSelector( + (state) => getTimeline(state, TimelineId.active) ?? timelineDefaults + ); + const { savedSearchId } = timeline; + + const queryClient = useQueryClient(); + + const { mutateAsync: saveSavedSearch } = useMutation({ + mutationFn: ({ + savedSearch, + savedSearchOptions, + }: { + savedSearch: SavedSearch; + savedSearchOptions: SaveSavedSearchOptions; + }) => savedSearchService.save(savedSearch, savedSearchOptions), + onSuccess: () => { + // Invalidate and refetch + queryClient.invalidateQueries({ queryKey: ['savedSearchById', savedSearchId] }); + }, + }); + + const defaultDiscoverAppState: DiscoverAppState = useMemo(() => { + return { + query: discoverDataService.query.queryString.getDefaultQuery(), + sort: [['@timestamp', 'desc']], + columns: [], + index: dataViewId ?? 'security-solution-default', + interval: 'auto', + filters: [], + hideChart: true, + grid: {}, + }; + }, [discoverDataService, dataViewId]); + + /* + * generates Appstate from a given saved Search object + * + * @param savedSearch + * + * */ + const getAppStateFromSavedSearch = useCallback( + (savedSearch: SavedSearch) => { + const appState = + discoverStateContainer.current?.appState.getAppStateFromSavedSearch(savedSearch); + return { + savedSearch, + appState, + }; + }, + [discoverStateContainer] + ); + + /* + * restores the url state of discover in timeline + * + * @param savedSearch + * */ + const restoreDiscoverAppStateFromSavedSearch = useCallback( + (savedSearch: SavedSearch) => { + const { appState } = getAppStateFromSavedSearch(savedSearch); + if (!appState) return; + discoverStateContainer.current?.appState.set(appState); + const timeRangeFromSavedSearch = savedSearch.timeRange; + discoverStateContainer.current?.globalState.set({ + ...discoverStateContainer.current?.globalState.get(), + time: timeRangeFromSavedSearch ?? defaultDiscoverTimeRange, + }); + }, + [getAppStateFromSavedSearch, discoverStateContainer] + ); + + /* + * resets discover state to a default value + * + * */ + const resetDiscoverAppState = useCallback(() => { + discoverStateContainer.current?.appState.set(defaultDiscoverAppState); + discoverStateContainer.current?.globalState.set({ + ...discoverStateContainer.current?.globalState.get(), + time: defaultDiscoverTimeRange, + }); + }, [defaultDiscoverAppState, discoverStateContainer]); + + const persistSavedSearch = useCallback( + async (savedSearch: SavedSearch, savedSearchOption: SaveSavedSearchOptions) => { + if (!discoverStateContainer) { + // eslint-disable-next-line no-console + console.log(`Saved search is not open since state container is null`); + return; + } + if (!savedSearch) return; + + function onError(error: Error) { + addError(error, { title: DISCOVER_SEARCH_SAVE_ERROR_TITLE }); + } + + try { + const id = await saveSavedSearch({ + savedSearch, + savedSearchOptions: savedSearchOption, + }); + if (id) { + return { id }; + } else { + addError(DISCOVER_SEARCH_SAVE_ERROR_UNKNOWN, { title: DISCOVER_SEARCH_SAVE_ERROR_TITLE }); + } + } catch (err) { + onError(err); + } + }, + [addError, discoverStateContainer, saveSavedSearch] + ); + + /* + * persists the given savedSearch + * + * */ + const updateSavedSearch = useCallback( + async (savedSearch: SavedSearch, timelineId: string) => { + dispatch( + startTimelineSaving({ + id: timelineId, + }) + ); + savedSearch.timeRestore = true; + savedSearch.timeRange = + savedSearch.timeRange ?? discoverDataService.query.timefilter.timefilter.getTime(); + savedSearch.tags = ['security-solution-default']; + + if (savedSearchId) { + savedSearch.id = savedSearchId; + } + try { + const response = await persistSavedSearch(savedSearch, { + onTitleDuplicate: () => {}, + copyOnSave: !savedSearchId, + }); + + if (!response || !response.id) { + throw new Error('Unknown Error occured'); + } + + if (!savedSearchId) { + dispatch( + timelineActions.updateSavedSearchId({ + id: TimelineId.active, + savedSearchId: response.id, + }) + ); + } + } catch (err) { + addError(DISCOVER_SEARCH_SAVE_ERROR_TITLE, { + title: DISCOVER_SEARCH_SAVE_ERROR_TITLE, + toastMessage: String(err), + }); + } finally { + dispatch( + endTimelineSaving({ + id: timelineId, + }) + ); + } + }, + [persistSavedSearch, savedSearchId, addError, dispatch, discoverDataService] + ); + + const actions = useMemo( + () => ({ + resetDiscoverAppState, + restoreDiscoverAppStateFromSavedSearch, + updateSavedSearch, + getAppStateFromSavedSearch, + defaultDiscoverAppState, + }), + [ + resetDiscoverAppState, + restoreDiscoverAppStateFromSavedSearch, + updateSavedSearch, + getAppStateFromSavedSearch, + defaultDiscoverAppState, + ] + ); + + return actions; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_context.tsx b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_context.tsx new file mode 100644 index 0000000000000..0d54b9e58df87 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_context.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useContext } from 'react'; +import { DiscoverInTimelineContext } from './context'; + +export const useDiscoverInTimelineContext = () => { + const discoverContext = useContext(DiscoverInTimelineContext); + if (!discoverContext) { + const errMessage = `useDiscoverInTimelineContext should only used within a tree with parent as DiscoverInTimelineContextProvider`; + throw new Error(errMessage); + } + + return discoverContext; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_alerts_by_process_ancestry.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_alerts_by_process_ancestry.test.tsx index 88685262ecc17..c74640c9cdc77 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_alerts_by_process_ancestry.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_alerts_by_process_ancestry.test.tsx @@ -49,7 +49,7 @@ const props = { }; describe('RelatedAlertsByProcessAncestry', () => { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('shows an accordion and does not fetch data right away', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.test.tsx index 4c95fd0f939ff..9c3b793197c9c 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.test.tsx @@ -19,7 +19,7 @@ const useUserPrivilegesMock = useUserPrivileges as jest.Mock; describe('AddEventNoteAction', () => { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('isDisabled', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/pin_event_action.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/pin_event_action.test.tsx index d803535b73dd4..1e30fdc8868bd 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/pin_event_action.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/pin_event_action.test.tsx @@ -19,7 +19,7 @@ const useUserPrivilegesMock = useUserPrivileges as jest.Mock; describe('PinEventAction', () => { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('isDisabled', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/local_storage/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/local_storage/index.test.tsx index d7dbfdeb5d026..e13d48354cc3e 100644 --- a/x-pack/plugins/security_solution/public/common/components/local_storage/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/local_storage/index.test.tsx @@ -39,7 +39,7 @@ jest.mock('../../lib/kibana', () => { describe('useLocalStorage', () => { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); test('it returns the expected value from local storage', async () => { diff --git a/x-pack/plugins/security_solution/public/common/components/search_bar/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/search_bar/index.test.tsx index adc097617e386..71c57edd9191c 100644 --- a/x-pack/plugins/security_solution/public/common/components/search_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/search_bar/index.test.tsx @@ -86,7 +86,7 @@ describe('SearchBarComponent', () => { const pollForSignalIndex = jest.fn(); beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('calls pollForSignalIndex on Refresh button click', () => { diff --git a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts index cb4bd92d205d7..c16f5ebb4bae5 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts @@ -33,6 +33,7 @@ export const useInitTimelineFromUrlParam = () => { updateIsLoading: (status: { id: string; isLoading: boolean }) => dispatch(timelineActions.updateIsLoading(status)), updateTimeline: dispatchUpdateTimeline(dispatch), + savedSearchId: initialState.savedSearchId, }); } }, diff --git a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_sync_timeline_url_param.ts b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_sync_timeline_url_param.ts index fc2e9b620c314..f016cbfd57cc8 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_sync_timeline_url_param.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_sync_timeline_url_param.ts @@ -17,7 +17,7 @@ import { URL_PARAM_KEY } from '../use_url_state'; export const useSyncTimelineUrlParam = () => { const updateUrlParam = useUpdateUrlParam(URL_PARAM_KEY.timeline); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { activeTab, graphEventId, show, savedObjectId } = useShallowEqualSelector( + const { activeTab, graphEventId, show, savedObjectId, savedSearchId } = useShallowEqualSelector( (state) => getTimeline(state, TimelineId.active) ?? {} ); @@ -27,7 +27,8 @@ export const useSyncTimelineUrlParam = () => { isOpen: show, activeTab, graphEventId: graphEventId ?? '', + savedSearchId: savedSearchId ? savedSearchId : undefined, }; updateUrlParam(params); - }, [activeTab, graphEventId, savedObjectId, show, updateUrlParam]); + }, [activeTab, graphEventId, savedObjectId, show, updateUrlParam, savedSearchId]); }; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.test.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.test.tsx index d17b9c16f9d2a..e69f70234153e 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.test.tsx +++ b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.test.tsx @@ -29,7 +29,7 @@ jest.mock('../../timelines/store/timeline', () => ({ describe('useResolveConflict', () => { const mockGetLegacyUrlConflict = jest.fn().mockReturnValue('Test!'); beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); // Mock rison format in actual url (useLocation as jest.Mock).mockReturnValue({ pathname: 'my/cool/path', diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.test.ts index c54259649448b..6aeb2a8b42d83 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.test.ts @@ -30,7 +30,7 @@ jest.mock('../../timelines/store/timeline', () => ({ describe('useResolveRedirect', () => { const mockRedirectLegacyUrl = jest.fn(); beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); // Mock rison format in actual url (useLocation as jest.Mock).mockReturnValue({ pathname: 'my/cool/path', diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 92db48980fdda..22965044c0838 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -52,6 +52,7 @@ import { UpsellingService } from '@kbn/security-solution-upselling/service'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; import { NavigationProvider } from '@kbn/security-solution-navigation'; import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; +import { savedSearchPluginMock } from '@kbn/saved-search-plugin/public/mocks'; const mockUiSettings: Record = { [DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' }, @@ -220,6 +221,7 @@ export const createStartServicesMock = ( upselling: new UpsellingService(), customDataService, uiActions: uiActionsPluginMock.createStartContract(), + savedSearch: savedSearchPluginMock.createStartContract(), } as unknown as StartServices; }; diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 2d8e49ad096e8..3306f3224a432 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -374,6 +374,8 @@ export const mockGlobalState: State = { filters: [], isSaving: false, itemsPerPageOptions: [10, 25, 50, 100], + savedSearchId: null, + isDiscoverSavedSearchLoaded: false, }, }, insertTimeline: null, diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 88aebe587a8d9..a6779272da763 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -38,6 +38,7 @@ import { SUB_PLUGINS_REDUCER } from './utils'; import { createSecuritySolutionStorageMock, localStorageMock } from './mock_local_storage'; import { ASSISTANT_FEATURE_ID, CASES_FEATURE_ID } from '../../../common/constants'; import { UserPrivilegesProvider } from '../components/user_privileges/user_privileges_context'; +import { MockDiscoverInTimelineContext } from '../components/discover_in_timeline/mocks/discover_in_timeline_provider'; const state: State = mockGlobalState; @@ -79,19 +80,21 @@ export const TestProvidersComponent: React.FC = ({ ({ eui: euiDarkVars, darkMode: true })}> - - - - - Promise.resolve(cellActions)} - > - {children} - - - - - + + + + + + Promise.resolve(cellActions)} + > + {children} + + + + + + @@ -117,29 +120,40 @@ const TestProvidersWithPrivilegesComponent: React.FC = ({ onDragEnd = jest.fn(), cellActions = [], }) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); return ( ({ eui: euiDarkVars, darkMode: true })}> - - - Promise.resolve(cellActions)} - > - {children} - - - + + + + + Promise.resolve(cellActions)} + > + {children} + + + + + diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index 0f861e5624d04..ef6b4d265a5a7 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -2026,6 +2026,7 @@ export const mockTimelineModel: TimelineModel = { templateTimelineId: null, templateTimelineVersion: null, version: '1', + savedSearchId: null, }; export const mockDataTableModel: DataTableModel = { @@ -2205,6 +2206,8 @@ export const defaultTimelineProps: CreateTimelineProps = { templateTimelineVersion: null, templateTimelineId: null, version: null, + savedSearchId: null, + isDiscoverSavedSearchLoaded: false, }, to: '2018-11-05T19:03:25.937Z', notes: null, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_lists_table/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_lists_table/index.test.tsx index 6c9e08f87adc2..af35e111034c8 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_lists_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_lists_table/index.test.tsx @@ -54,7 +54,7 @@ describe.skip('ExceptionsAddToListsTable', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('it displays loading state while fetching data', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 62b1d8e857703..23b8fe50be532 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -265,7 +265,6 @@ describe('alert actions', () => { // jest carries state between mocked implementations when using // spyOn. So now we're doing all three of these. // https://github.com/facebook/jest/issues/7136#issuecomment-565976599 - jest.resetAllMocks(); jest.clearAllMocks(); mockGetExceptionFilter = jest.fn().mockResolvedValue(undefined); @@ -452,6 +451,8 @@ describe('alert actions', () => { templateTimelineId: null, templateTimelineVersion: null, version: null, + savedSearchId: null, + isDiscoverSavedSearchLoaded: false, }, to: '2018-11-05T19:03:25.937Z', resolveTimelineConfig: undefined, diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.test.tsx index 1ca40a32195f8..1f31ec6def5a2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.test.tsx @@ -13,7 +13,7 @@ import * as userInfo from '../../user_info'; describe('need_admin_for_update_callout', () => { afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('hasIndexManage is "null"', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/footer.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/footer.test.tsx index 4de87ed33a87b..cb15e5002ca4c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/footer.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/footer.test.tsx @@ -26,7 +26,7 @@ jest.mock('../../../../common/lib/kibana', () => { describe('EQL footer', () => { describe('EQL Settings', () => { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('EQL settings button is enable when popover is NOT open', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx index 88073254bfbbe..05fa86a1fa1df 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx @@ -138,7 +138,7 @@ describe('QueryBarDefineRule', () => { getByTestId('open-timeline-modal').click(); await act(async () => { - fireEvent.click(getByTestId('title-10849df0-7b44-11e9-a608-ab3d811609')); + fireEvent.click(getByTestId('timeline-title-10849df0-7b44-11e9-a608-ab3d811609')); }); expect(onOpenTimeline).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx index 02f5520af329d..1221d855d5aa9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx @@ -45,7 +45,7 @@ describe('RuleActionsOverflow', () => { jest.clearAllMocks(); }); afterAll(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('rules details menu panel', () => { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx index ac2917e580e44..ce92e38dab1d0 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx @@ -100,7 +100,7 @@ describe('useAlertsPrivileges', () => { let appToastsMock: jest.Mocked>; beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); appToastsMock = useAppToastsMock.create(); (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); useUserPrivilegesMock.mockReturnValue(userPrivilegesInitial); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx index 2430a605a2185..01bbe8c807ec2 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx @@ -18,7 +18,7 @@ jest.mock('../../../../common/hooks/use_app_toasts'); describe('useCasesFromAlerts hook', () => { let appToastsMock: jest.Mocked>; beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); appToastsMock = useAppToastsMock.create(); (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx index 4e2110c1b0013..8966232ef2b53 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx @@ -15,13 +15,13 @@ import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; jest.mock('./api'); jest.mock('../../../../common/hooks/use_app_toasts'); jest.mock('../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'); +jest.mock('../../../../timelines/components/timeline/discover_tab_content'); describe('useSignalIndex', () => { let appToastsMock: jest.Mocked>; beforeEach(() => { jest.clearAllMocks(); - jest.resetAllMocks(); appToastsMock = useAppToastsMock.create(); (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.test.tsx index 4c5bb720d400c..b36ab4a99b371 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.test.tsx @@ -21,7 +21,7 @@ import { ChartContextMenu } from '.'; describe('ChartContextMenu', () => { const queryId = 'abcd'; - beforeEach(() => jest.resetAllMocks()); + beforeEach(() => jest.clearAllMocks()); test('it renders the chart context menu button', () => { render( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts index b201674a2494f..e842f998bd5c1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts @@ -18,7 +18,7 @@ import { import * as i18n from './translations'; describe('helpers', () => { - beforeEach(() => jest.resetAllMocks()); + beforeEach(() => jest.clearAllMocks()); describe('getButtonProperties', () => { test('it returns the expected properties when alertViewSelection is Trend', () => { diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts index e6c261ab6588b..cc2b01f75da57 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts @@ -34,6 +34,7 @@ export const fetchRiskScorePreview = async ({ params: RiskScorePreviewRequestSchema; }): Promise => { return KibanaServices.get().http.fetch(RISK_SCORE_PREVIEW_URL, { + version: '1', method: 'POST', body: JSON.stringify(params), signal, @@ -49,6 +50,7 @@ export const fetchRiskEngineStatus = async ({ signal?: AbortSignal; }): Promise => { return KibanaServices.get().http.fetch(RISK_ENGINE_STATUS_URL, { + version: '1', method: 'GET', signal, }); @@ -59,6 +61,7 @@ export const fetchRiskEngineStatus = async ({ */ export const initRiskEngine = async (): Promise => { return KibanaServices.get().http.fetch(RISK_ENGINE_INIT_URL, { + version: '1', method: 'POST', }); }; @@ -68,6 +71,7 @@ export const initRiskEngine = async (): Promise => { */ export const enableRiskEngine = async (): Promise => { return KibanaServices.get().http.fetch(RISK_ENGINE_ENABLE_URL, { + version: '1', method: 'POST', }); }; @@ -77,6 +81,7 @@ export const enableRiskEngine = async (): Promise => { */ export const disableRiskEngine = async (): Promise => { return KibanaServices.get().http.fetch(RISK_ENGINE_DISABLE_URL, { + version: '1', method: 'POST', }); }; diff --git a/x-pack/plugins/security_solution/public/exceptions/components/exceptions_list_card/index.test.tsx b/x-pack/plugins/security_solution/public/exceptions/components/exceptions_list_card/index.test.tsx index 08061169a74a6..d59168d31afad 100644 --- a/x-pack/plugins/security_solution/public/exceptions/components/exceptions_list_card/index.test.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/components/exceptions_list_card/index.test.tsx @@ -83,7 +83,7 @@ describe('ExceptionsListCard', () => { (useListDetailsView as jest.Mock).mockReturnValue(getMockUseListDetailsView()); }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('should display expired exception confirmation modal when "showIncludeExpiredExceptionsModal" is "true"', () => { diff --git a/x-pack/plugins/security_solution/public/explore/network/pages/details/index.test.tsx b/x-pack/plugins/security_solution/public/explore/network/pages/details/index.test.tsx index 60f3bc086a263..5ab0674807efc 100644 --- a/x-pack/plugins/security_solution/public/explore/network/pages/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/explore/network/pages/details/index.test.tsx @@ -148,7 +148,7 @@ describe('Network Details', () => { }); afterAll(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); const state: State = mockGlobalState; diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/prevalence_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/prevalence_details.test.tsx index b8f09506ef46b..a76fb83074451 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/components/prevalence_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/left/components/prevalence_details.test.tsx @@ -18,6 +18,7 @@ import { PREVALENCE_DETAILS_UPSELL_TEST_ID, PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID, PREVALENCE_DETAILS_TABLE_VALUE_CELL_TEST_ID, + PREVALENCE_DETAILS_TABLE_UPSELL_CELL_TEST_ID, } from './test_ids'; import { usePrevalence } from '../../shared/hooks/use_prevalence'; import { TestProviders } from '../../../common/mock'; @@ -54,6 +55,8 @@ const panelContextValue = { dataFormattedForFieldBrowser: [], } as unknown as LeftPanelContext; +const UPSELL_MESSAGE = 'Host and user prevalence are only available with a'; + const renderPrevalenceDetails = () => render( @@ -70,7 +73,7 @@ describe('PrevalenceDetails', () => { licenseServiceMock.isPlatinumPlus.mockReturnValue(true); }); - it('should render the table with all columns if license is platinum', () => { + it('should render the table with all data if license is platinum', () => { const field1 = 'field1'; const field2 = 'field2'; (usePrevalence as jest.Mock).mockReturnValue({ @@ -117,6 +120,38 @@ describe('PrevalenceDetails', () => { expect(queryByText(NO_DATA_MESSAGE)).not.toBeInTheDocument(); }); + it('should hide data in prevalence columns if license is not platinum', () => { + const field1 = 'field1'; + + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + (usePrevalence as jest.Mock).mockReturnValue({ + loading: false, + error: false, + data: [ + { + field: field1, + values: ['value1'], + alertCount: 1, + docCount: 1, + hostPrevalence: 0.05, + userPrevalence: 0.1, + }, + ], + }); + + const { getByTestId, getAllByTestId } = renderPrevalenceDetails(); + + expect(getByTestId(PREVALENCE_DETAILS_TABLE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(PREVALENCE_DETAILS_UPSELL_TEST_ID)).toHaveTextContent(UPSELL_MESSAGE); + expect(getAllByTestId(PREVALENCE_DETAILS_TABLE_UPSELL_CELL_TEST_ID).length).toEqual(2); + expect( + getByTestId(PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID) + ).not.toHaveTextContent('5%'); + expect( + getByTestId(PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID) + ).not.toHaveTextContent('10%'); + }); + it('should render formatted numbers for the alert and document count columns', () => { (usePrevalence as jest.Mock).mockReturnValue({ loading: false, diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/prevalence_details.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/prevalence_details.tsx index 418e895dda3a2..bb92a793506b5 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/components/prevalence_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/left/components/prevalence_details.tsx @@ -19,6 +19,7 @@ import { EuiSuperDatePicker, EuiText, EuiToolTip, + useEuiTheme, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedCount } from '../../../common/components/formatted_number'; @@ -36,6 +37,7 @@ import { PREVALENCE_DETAILS_DATE_PICKER_TEST_ID, PREVALENCE_DETAILS_TABLE_TEST_ID, PREVALENCE_DETAILS_UPSELL_TEST_ID, + PREVALENCE_DETAILS_TABLE_UPSELL_CELL_TEST_ID, } from './test_ids'; import { useLeftPanelContext } from '../context'; import { @@ -49,6 +51,19 @@ export const PREVALENCE_TAB_ID = 'prevalence-details'; const DEFAULT_FROM = 'now-30d'; const DEFAULT_TO = 'now'; +/** + * Component that renders a grey box to indicate the user doesn't have proper license to view the actual data + */ +export const LicenseProtectedCell: React.FC = () => { + const { euiTheme } = useEuiTheme(); + return ( +
      + ); +}; + interface PrevalenceDetailsRow extends PrevalenceData { /** * From datetime selected in the date picker to pass to timeline @@ -58,6 +73,10 @@ interface PrevalenceDetailsRow extends PrevalenceData { * To datetime selected in the date picker to pass to timeline */ to: string; + /** + * License to drive the rendering of the last 2 prevalence columns + */ + isPlatinumPlus: boolean; } const columns: Array> = [ @@ -196,7 +215,6 @@ const columns: Array> = [ width: '10%', }, { - field: 'hostPrevalence', name: ( > = [ ), 'data-test-subj': PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID, - render: (hostPrevalence: number) => ( - {`${Math.round(hostPrevalence * 100)}%`} + render: (data: PrevalenceDetailsRow) => ( + <> + {data.isPlatinumPlus ? ( + {`${Math.round(data.hostPrevalence * 100)}%`} + ) : ( + + )} + ), width: '10%', }, { - field: 'userPrevalence', name: ( > = [ ), 'data-test-subj': PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID, - render: (userPrevalence: number) => ( - {`${Math.round(userPrevalence * 100)}%`} + render: (data: PrevalenceDetailsRow) => ( + <> + {data.isPlatinumPlus ? ( + {`${Math.round(data.userPrevalence * 100)}%`} + ) : ( + + )} + ), width: '10%', }, @@ -312,10 +341,10 @@ export const PrevalenceDetails: React.FC = () => { }, }); - // add timeRange to pass it down to timeline + // add timeRange to pass it down to timeline and license to drive the rendering of the last 2 prevalence columns const items = useMemo( - () => data.map((item) => ({ ...item, from: absoluteStart, to: absoluteEnd })), - [data, absoluteStart, absoluteEnd] + () => data.map((item) => ({ ...item, from: absoluteStart, to: absoluteEnd, isPlatinumPlus })), + [data, absoluteStart, absoluteEnd, isPlatinumPlus] ); const upsell = ( @@ -323,13 +352,13 @@ export const PrevalenceDetails: React.FC = () => { ), diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/left/components/test_ids.ts index 855f8379e3438..b3ab7dc341c7d 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/left/components/test_ids.ts @@ -34,6 +34,8 @@ export const PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID = `${PREVALENCE_DETAILS_TABLE_TEST_ID}HostPrevalenceCell` as const; export const PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID = `${PREVALENCE_DETAILS_TABLE_TEST_ID}UserPrevalenceCell` as const; +export const PREVALENCE_DETAILS_TABLE_UPSELL_CELL_TEST_ID = + `${PREVALENCE_DETAILS_TABLE_TEST_ID}UpsellCell` as const; /* Entities */ diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_preview.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_preview.test.tsx index dabc988aa2807..22afc55bbd6cf 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_preview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_preview.test.tsx @@ -34,7 +34,7 @@ const NO_DATA_MESSAGE = 'An error is preventing this alert from being analyzed.' describe('', () => { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('shows analyzer preview correctly when documentId and index are present', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_preview_container.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_preview_container.test.tsx index 2adbf4e01d705..14b475f95c3c7 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_preview_container.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_preview_container.test.tsx @@ -62,7 +62,7 @@ const renderAnalyzerPreview = () => describe('AnalyzerPreviewContainer', () => { afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('should render component and link in header', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/session_preview.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/session_preview.test.tsx index 279954cd8d996..3f93b24e4b018 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/session_preview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/session_preview.test.tsx @@ -39,7 +39,7 @@ const renderSessionPreview = () => describe('SessionPreview', () => { afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('renders session preview with all data', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/session_preview_container.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/session_preview_container.test.tsx index ef0d52cead5fb..e47673721e5b7 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/session_preview_container.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/session_preview_container.test.tsx @@ -51,7 +51,7 @@ const renderSessionPreview = () => describe('SessionPreviewContainer', () => { afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('should render component and link in header', () => { diff --git a/x-pack/plugins/security_solution/public/index.ts b/x-pack/plugins/security_solution/public/index.ts index 28b99af129b1d..32378330e27dd 100644 --- a/x-pack/plugins/security_solution/public/index.ts +++ b/x-pack/plugins/security_solution/public/index.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import type { PluginInitializerContext } from '@kbn/core/public'; import { Plugin } from './plugin'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts index f9818447c2e0d..056577f7944ef 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts @@ -1271,7 +1271,28 @@ export const AdvancedPolicySchema: AdvancedPolicySchemaType[] = [ 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.events.api', { defaultMessage: - 'Controls whether API events are enabled. Set to false to disable API event collection. Default: true', + 'Controls whether ETW API events are enabled. Set to false to disable ETW event collection. Default: true', + } + ), + }, + { + key: 'windows.advanced.events.api_disabled', + first_supported_version: '8.11', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.events.api_disabled', + { + defaultMessage: 'A comma separated list of API names to selectively disable.', + } + ), + }, + { + key: 'windows.advanced.events.api_verbose', + first_supported_version: '8.11', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.events.api_verbose', + { + defaultMessage: + 'Controls whether high volume API events are forwarded. Event filtering is recommended if enabled. Default: false', } ), }, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts index 27c86e0e6ca0a..1c7a8e0b18a61 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts @@ -273,7 +273,7 @@ describe('policy details: ', () => { meta: { license: '', cloud: false, - license_uid: '', + license_uuid: '', cluster_name: '', cluster_uuid: '', serverless: false, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.test.tsx index 16c84436684f1..64c0473a55393 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.test.tsx @@ -70,7 +70,7 @@ describe('Policy Windows Event Collection Card', () => { 'Operating system' + 'Windows 8 / 8 event collections enabled' + 'Events' + - 'Credential Access' + + 'API' + 'DLL and Driver Load' + 'DNS' + 'File' + @@ -98,7 +98,7 @@ describe('Policy Windows Event Collection Card', () => { 'Windows ' + '6 / 8 event collections enabled' + 'Events' + - 'Credential Access' + + 'API' + 'DLL and Driver Load' + 'Network' + 'Process' + diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.tsx index 3fadf3665d9fd..fd20184113468 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.tsx @@ -17,7 +17,7 @@ const OPTIONS: ReadonlyArray> = [ name: i18n.translate( 'xpack.securitySolution.endpoint.policyDetailsConfig.windows.events.credentialAccess', { - defaultMessage: 'Credential Access', + defaultMessage: 'API', } ), protectionField: 'credential_access', diff --git a/x-pack/plugins/security_solution/public/overview/pages/data_quality.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/data_quality.test.tsx index ffb3b616f2389..4f97250752202 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/data_quality.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/data_quality.test.tsx @@ -82,7 +82,7 @@ describe('DataQuality', () => { const defaultIlmPhases = `${HOT}${WARM}${UNMANAGED}`; beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); mockUseSourcererDataView.mockReturnValue(defaultUseSourcererReturn); mockUseSignalIndex.mockReturnValue(defaultUseSignalIndexReturn); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index ca43ba15dbf31..b2f113ecbbbad 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -327,6 +327,7 @@ export interface QueryTimelineById { isLoading: boolean; }) => Action<{ id: string; isLoading: boolean }>; updateTimeline: DispatchUpdateTimeline; + savedSearchId?: string; } export const queryTimelineById = ({ @@ -340,6 +341,7 @@ export const queryTimelineById = ({ openTimeline = true, updateIsLoading, updateTimeline, + savedSearchId, }: QueryTimelineById) => { updateIsLoading({ id: TimelineId.active, isLoading: true }); if (timelineId == null) { @@ -355,6 +357,7 @@ export const queryTimelineById = ({ activeTab: activeTimelineTab, show: openTimeline, initialized: true, + savedSearchId: savedSearchId ?? null, }, })(); updateIsLoading({ id: TimelineId.active, isLoading: false }); @@ -395,6 +398,7 @@ export const queryTimelineById = ({ graphEventId, show: openTimeline, dateRange: { start: from, end: to }, + savedSearchId: timeline.savedSearchId, }, to, })(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx index 471561d6eeb0e..91d865960c813 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx @@ -642,7 +642,9 @@ describe('StatefulOpenTimeline', () => { await waitFor(() => { wrapper - .find(`[data-test-subj="title-${mockOpenTimelineQueryResults.timeline[0].savedObjectId}"]`) + .find( + `[data-test-subj="timeline-title-${mockOpenTimelineQueryResults.timeline[0].savedObjectId}"]` + ) .last() .simulate('click'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx index 6b5bfcd1f10c3..fa7f290abb20a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx @@ -271,7 +271,10 @@ describe('#getCommonColumns', () => { ); expect( - wrapper.find(`[data-test-subj="title-${mockResults[0].savedObjectId}"]`).first().text() + wrapper + .find(`[data-test-subj="timeline-title-${mockResults[0].savedObjectId}"]`) + .first() + .text() ).toEqual(mockResults[0].title); }); @@ -314,7 +317,10 @@ describe('#getCommonColumns', () => { ); expect( - wrapper.find(`[data-test-subj="title-${missingTitle[0].savedObjectId}"]`).first().text() + wrapper + .find(`[data-test-subj="timeline-title-${missingTitle[0].savedObjectId}"]`) + .first() + .text() ).toEqual(i18n.UNTITLED_TIMELINE); }); @@ -357,7 +363,7 @@ describe('#getCommonColumns', () => { expect( wrapper - .find(`[data-test-subj="title-${withJustWhitespaceTitle[0].savedObjectId}"]`) + .find(`[data-test-subj="timeline-title-${withJustWhitespaceTitle[0].savedObjectId}"]`) .first() .text() ).toEqual(i18n.UNTITLED_TIMELINE); @@ -397,7 +403,10 @@ describe('#getCommonColumns', () => { ); expect( - wrapper.find(`[data-test-subj="title-${mockResults[0].savedObjectId}"]`).first().exists() + wrapper + .find(`[data-test-subj="timeline-title-${mockResults[0].savedObjectId}"]`) + .first() + .exists() ).toBe(true); }); @@ -418,7 +427,10 @@ describe('#getCommonColumns', () => { ); expect( - wrapper.find(`[data-test-subj="title-${mockResults[0].savedObjectId}"]`).first().exists() + wrapper + .find(`[data-test-subj="timeline-title-${mockResults[0].savedObjectId}"]`) + .first() + .exists() ).toBe(false); }); @@ -438,7 +450,7 @@ describe('#getCommonColumns', () => { ); wrapper - .find(`[data-test-subj="title-${mockResults[0].savedObjectId}"]`) + .find(`[data-test-subj="timeline-title-${mockResults[0].savedObjectId}"]`) .last() .simulate('click'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx index d3c5357f9e45e..649dde8664f79 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx @@ -69,7 +69,7 @@ export const getCommonColumns = ({ render: (title: string, timelineResult: OpenTimelineResult) => timelineResult.savedObjectId != null ? ( onOpenTimeline({ duplicate: false, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx index 7b47acfff0586..d9c30f312a859 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx @@ -58,7 +58,7 @@ const defaultProps = { describe('reasonColumnRenderer', () => { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('isIntance', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/__mocks__/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/__mocks__/index.tsx new file mode 100644 index 0000000000000..728f24b11015e --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/__mocks__/index.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MockDiscoverTabContent } from '../mocks/discover_tab_content'; + +export const DiscoverTabContent = MockDiscoverTabContent; + +// eslint-disable-next-line import/no-default-export +export default DiscoverTabContent; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/index.test.tsx index db874f9a81245..770f9281234ee 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/index.test.tsx @@ -9,21 +9,29 @@ import React from 'react'; import { TestProviders } from '../../../../common/mock'; import DiscoverTabContent from '.'; import { render, screen, waitFor } from '@testing-library/react'; +import { TimelineId } from '../../../../../common/types'; const TestComponent = () => { return ( - + ); }; describe('Discover Tab Content', () => { - it('renders', async () => { + it('should render', async () => { render(); await waitFor(() => { expect(screen.getByTestId('timeline-embedded-discover')).toBeInTheDocument(); }); }); + + // issue for enabling below tests: https://github.com/elastic/kibana/issues/165913 + it.skip('should load saved search when a saved timeline is restored', () => {}); + it.skip('should reset the discover state when new timeline is created', () => {}); + it.skip('should update saved search if timeline title and description are updated', () => {}); + it.skip('should should not update saved search if the fetched saved search is same as discover updated saved search', () => {}); + it.skip('should update saved search if discover time is update', () => {}); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/index.tsx index deb52a77a5012..abce4c9efa9a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/index.tsx @@ -5,20 +5,32 @@ * 2.0. */ +import type { FC } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; import type { CustomizationCallback } from '@kbn/discover-plugin/public/customizations/types'; import { createGlobalStyle } from 'styled-components'; import type { ScopedHistory } from '@kbn/core/public'; -import type { DiscoverStateContainer } from '@kbn/discover-plugin/public'; import type { Subscription } from 'rxjs'; import type { DataView } from '@kbn/data-views-plugin/common'; +import { useQuery } from '@tanstack/react-query'; +import { debounce, isEqualWith } from 'lodash'; +import type { SavedSearch } from '@kbn/saved-search-plugin/common'; +import type { TimeRange } from '@kbn/es-query'; +import { useDispatch } from 'react-redux'; +import { useDiscoverInTimelineContext } from '../../../../common/components/discover_in_timeline/use_discover_in_timeline_context'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; import { useKibana } from '../../../../common/lib/kibana'; import { useDiscoverState } from './use_discover_state'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { useSetDiscoverCustomizationCallbacks } from './customizations/use_set_discover_customizations'; import { EmbeddedDiscoverContainer } from './styles'; +import { timelineSelectors } from '../../../store/timeline'; +import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { timelineDefaults } from '../../../store/timeline/defaults'; +import { savedSearchComparator } from './utils'; +import { setIsDiscoverSavedSearchLoaded } from '../../../store/timeline/actions'; +import { GET_TIMELINE_DISCOVER_SAVED_SEARCH_TITLE } from './translations'; const HideSearchSessionIndicatorBreadcrumbIcon = createGlobalStyle` [data-test-subj='searchSessionIndicator'] { @@ -26,33 +38,166 @@ const HideSearchSessionIndicatorBreadcrumbIcon = createGlobalStyle` } `; -export const DiscoverTabContent = () => { +interface DiscoverTabContentProps { + timelineId: string; +} + +export const DiscoverTabContent: FC = ({ timelineId }) => { const history = useHistory(); const { - services: { customDataService: discoverDataService, discover, dataViews: dataViewService }, + services: { + customDataService: discoverDataService, + discover, + dataViews: dataViewService, + savedSearch: savedSearchService, + }, } = useKibana(); + const dispatch = useDispatch(); + const { dataViewId } = useSourcererDataView(SourcererScopeName.detections); const [dataView, setDataView] = useState(); - - const stateContainerRef = useRef(); + const [discoverTimerange, setDiscoverTimerange] = useState(); const discoverAppStateSubscription = useRef(); const discoverInternalStateSubscription = useRef(); const discoverSavedSearchStateSubscription = useRef(); + const discoverTimerangeSubscription = useRef(); - const discoverCustomizationCallbacks = useSetDiscoverCustomizationCallbacks(); + const { + discoverStateContainer, + setDiscoverStateContainer, + getAppStateFromSavedSearch, + updateSavedSearch, + restoreDiscoverAppStateFromSavedSearch, + resetDiscoverAppState, + } = useDiscoverInTimelineContext(); const { discoverAppState, - discoverInternalState, discoverSavedSearchState, setDiscoverSavedSearchState, setDiscoverInternalState, setDiscoverAppState, } = useDiscoverState(); + const discoverCustomizationCallbacks = useSetDiscoverCustomizationCallbacks(); + + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const timeline = useShallowEqualSelector( + (state) => getTimeline(state, timelineId) ?? timelineDefaults + ); + const { + status, + savedSearchId, + activeTab, + savedObjectId, + title, + description, + isDiscoverSavedSearchLoaded = false, + } = timeline; + + const setSavedSearchLoaded = useCallback( + (value: boolean) => { + dispatch( + setIsDiscoverSavedSearchLoaded({ + id: timelineId, + isDiscoverSavedSearchLoaded: value, + }) + ); + }, + [dispatch, timelineId] + ); + + const { data: savedSearchById, isFetching } = useQuery({ + queryKey: ['savedSearchById', savedSearchId ?? ''], + queryFn: () => (savedSearchId ? savedSearchService.get(savedSearchId) : Promise.resolve(null)), + }); + + useEffect(() => { + if (!savedObjectId) return; + setSavedSearchLoaded(false); + }, [savedObjectId, setSavedSearchLoaded]); + + useEffect(() => { + if (isFetching) return; // no-op is fetch is in progress + if (isDiscoverSavedSearchLoaded) return; // no-op if saved search has been already loaded + if (!savedSearchById) { + // nothing to restore if savedSearchById is null + if (status === 'draft') { + resetDiscoverAppState(); + } + setSavedSearchLoaded(true); + return; + } + restoreDiscoverAppStateFromSavedSearch(savedSearchById); + setSavedSearchLoaded(true); + }, [ + discoverStateContainer, + savedSearchId, + isDiscoverSavedSearchLoaded, + status, + activeTab, + resetDiscoverAppState, + savedSearchById, + getAppStateFromSavedSearch, + restoreDiscoverAppStateFromSavedSearch, + isFetching, + setSavedSearchLoaded, + ]); + + const getCombinedDiscoverSavedSearchState: () => SavedSearch | undefined = useCallback(() => { + if (!discoverSavedSearchState) return; + return { + ...(discoverStateContainer.current?.savedSearchState.getState() ?? discoverSavedSearchState), + timeRange: discoverDataService.query.timefilter.timefilter.getTime(), + refreshInterval: discoverStateContainer.current?.globalState.get()?.refreshInterval, + breakdownField: discoverStateContainer.current?.appState.getState().breakdownField, + rowsPerPage: discoverStateContainer.current?.appState.getState().rowsPerPage, + title: GET_TIMELINE_DISCOVER_SAVED_SEARCH_TITLE(title), + description, + }; + }, [ + discoverSavedSearchState, + discoverStateContainer, + discoverDataService.query.timefilter.timefilter, + title, + description, + ]); + + const combinedDiscoverSavedSearchStateRef = useRef(); + + const debouncedUpdateSavedSearch = useMemo( + () => debounce(updateSavedSearch, 300), + [updateSavedSearch] + ); + + useEffect(() => { + if (isFetching) return; + if (!isDiscoverSavedSearchLoaded) return; + if (!savedObjectId) return; + if (!status || status === 'draft') return; + const latestState = getCombinedDiscoverSavedSearchState(); + if (!latestState || combinedDiscoverSavedSearchStateRef.current === latestState) return; + if (isEqualWith(latestState, savedSearchById, savedSearchComparator)) return; + debouncedUpdateSavedSearch(latestState, timelineId); + combinedDiscoverSavedSearchStateRef.current = latestState; + }, [ + getCombinedDiscoverSavedSearchState, + debouncedUpdateSavedSearch, + savedSearchById, + updateSavedSearch, + isDiscoverSavedSearchLoaded, + activeTab, + status, + discoverTimerange, + savedObjectId, + isFetching, + timelineId, + dispatch, + ]); + useEffect(() => { if (!dataViewId) return; dataViewService.get(dataViewId).then(setDataView); @@ -64,21 +209,29 @@ export const DiscoverTabContent = () => { discoverAppStateSubscription.current, discoverInternalStateSubscription.current, discoverSavedSearchStateSubscription.current, + discoverTimerangeSubscription.current, ].forEach((sub) => { if (sub) sub.unsubscribe(); }); }; return unSubscribeAll; - }, []); + }, [discoverStateContainer]); const initialDiscoverCustomizationCallback: CustomizationCallback = useCallback( async ({ stateContainer }) => { - stateContainerRef.current = stateContainer; + setDiscoverStateContainer(stateContainer); + let savedSearchAppState; + if (savedSearchId) { + const localSavedSearch = await savedSearchService.get(savedSearchId); + savedSearchAppState = getAppStateFromSavedSearch(localSavedSearch); + } + + const finalAppState = savedSearchAppState?.appState ?? discoverAppState; - if (discoverAppState && discoverInternalState && discoverSavedSearchState) { - stateContainer.appState.set(discoverAppState); - await stateContainer.appState.replaceUrlState(discoverAppState); + if (finalAppState) { + stateContainer.appState.set(finalAppState); + await stateContainer.appState.replaceUrlState(finalAppState); } else { // set initial dataView Id if (dataView) stateContainer.actions.setDataView(dataView); @@ -101,18 +254,30 @@ export const DiscoverTabContent = () => { }, }); + const timeRangeSub = discoverDataService.query.timefilter.timefilter + .getTimeUpdate$() + .subscribe({ + next: () => { + setDiscoverTimerange(discoverDataService.query.timefilter.timefilter.getTime()); + }, + }); + discoverAppStateSubscription.current = unsubscribeState; discoverInternalStateSubscription.current = internalStateSubscription; discoverSavedSearchStateSubscription.current = savedSearchStateSub; + discoverTimerangeSubscription.current = timeRangeSub; }, [ discoverAppState, - discoverInternalState, - discoverSavedSearchState, setDiscoverSavedSearchState, setDiscoverInternalState, setDiscoverAppState, dataView, + setDiscoverStateContainer, + getAppStateFromSavedSearch, + discoverDataService.query.timefilter.timefilter, + savedSearchId, + savedSearchService, ] ); @@ -125,13 +290,14 @@ export const DiscoverTabContent = () => { () => ({ data: discoverDataService, filterManager: discoverDataService.query.filterManager, + timefilter: discoverDataService.query.timefilter.timefilter, }), [discoverDataService] ); const DiscoverContainer = discover.DiscoverContainer; - const isLoading = !dataView; + const isLoading = Boolean(!dataView) || !isDiscoverSavedSearchLoaded; return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/translations.ts new file mode 100644 index 0000000000000..e97da8b29c65e --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const GET_TIMELINE_DISCOVER_SAVED_SEARCH_TITLE = (title: string) => + i18n.translate('xpack.securitySolution.timelines.discoverInTimeline.savedSearchTitle', { + defaultMessage: 'Saved search for timeline - {title}', + values: { title }, + }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/utils/index.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/utils/index.test.ts new file mode 100644 index 0000000000000..3b25737b25278 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/utils/index.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; +import { buildDataViewMock, shallowMockedFields } from '@kbn/discover-utils/src/__mocks__'; +import { savedSearchComparator } from '.'; + +const customQuery = { + language: 'kuery', + query: '_id: *', +}; + +const firstDataViewMock = buildDataViewMock({ + name: 'first-data-view', + fields: shallowMockedFields, +}); + +const secondDataViewMock = buildDataViewMock({ + name: 'second-data-view', + fields: shallowMockedFields, +}); + +describe('savedSearchComparator', () => { + const firstMockSavedSearch = { + id: 'first', + title: 'first title', + breakdownField: 'firstBreakdown Field', + searchSource: createSearchSourceMock({ + index: firstDataViewMock, + query: customQuery, + }), + }; + + const secondMockSavedSearch = { + id: 'second', + title: 'second title', + breakdownField: 'second Breakdown Field', + searchSource: createSearchSourceMock({ + index: secondDataViewMock, + query: customQuery, + }), + }; + it('should result true when saved search is same', () => { + const result = savedSearchComparator(firstMockSavedSearch, { ...firstMockSavedSearch }); + expect(result).toBe(true); + }); + + it('should return false index is different', () => { + const newMockedSavedSearch = { + ...firstMockSavedSearch, + searchSource: secondMockSavedSearch.searchSource, + }; + + const result = savedSearchComparator(firstMockSavedSearch, newMockedSavedSearch); + + expect(result).toBe(false); + }); + + it('should return false when query is different', () => { + const newMockedSavedSearch = { + ...firstMockSavedSearch, + searchSource: createSearchSourceMock({ + index: firstDataViewMock, + query: { + ...customQuery, + query: '*', + }, + }), + }; + + const result = savedSearchComparator(firstMockSavedSearch, newMockedSavedSearch); + + expect(result).toBe(false); + }); + + it('should result false when title is different', () => { + const newMockedSavedSearch = { + ...firstMockSavedSearch, + title: 'new-title', + }; + const result = savedSearchComparator(firstMockSavedSearch, newMockedSavedSearch); + + expect(result).toBe(false); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/utils/index.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/utils/index.ts new file mode 100644 index 0000000000000..26340c12add52 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/discover_tab_content/utils/index.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedSearch } from '@kbn/saved-search-plugin/common'; +import { isEqual, pick } from 'lodash'; + +export const savedSearchComparator = ( + inputSavedSearch: SavedSearch | null, + existingSavedSearch: SavedSearch | null +) => { + const inputSavedSearchWithFields = { + ...inputSavedSearch, + fields: inputSavedSearch?.searchSource?.getFields(), + }; + + const existingSavedSearchWithFields = { + ...existingSavedSearch, + fields: existingSavedSearch?.searchSource?.getFields(), + }; + + const keysToSelect = [ + 'columns', + 'grid', + 'hideChart', + 'sort', + 'timeRange', + 'fields.filter', + 'fields.index.id', + 'fields.query', + 'title', + 'description', + ]; + + const modifiedInputSavedSearch = pick(inputSavedSearchWithFields, keysToSelect); + const modifiedExistingSavedSearch = pick(existingSavedSearchWithFields, keysToSelect); + + const result = isEqual(modifiedInputSavedSearch, modifiedExistingSavedSearch); + return result; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx index fcecef0d4c212..ac69b86ec5803 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx @@ -8,13 +8,13 @@ import type { ReactWrapper } from 'enzyme'; import { mount } from 'enzyme'; import React from 'react'; -import { Provider as ReduxStoreProvider } from 'react-redux'; import { mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, createSecuritySolutionStorageMock, + TestProviders, } from '../../../../common/mock'; import type { State } from '../../../../common/store'; import { createStore } from '../../../../common/store'; @@ -54,9 +54,9 @@ describe('NewTemplateTimeline', () => { }); wrapper = mount( - + - + ); }); @@ -91,9 +91,9 @@ describe('NewTemplateTimeline', () => { }); wrapper = mount( - + - + ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx index ad0a8b871bbe5..4742bb995fba3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx @@ -22,6 +22,7 @@ import { sourcererActions, sourcererSelectors } from '../../../../common/store/s import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { appActions } from '../../../../common/store/app'; import type { TimeRange } from '../../../../common/store/inputs/model'; +import { useDiscoverInTimelineContext } from '../../../../common/components/discover_in_timeline/use_discover_in_timeline_context'; interface Props { timelineId?: string; @@ -39,6 +40,8 @@ export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: P const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); const globalTimeRange = useDeepEqualSelector(inputsSelectors.globalTimeRangeSelector); + const { resetDiscoverAppState } = useDiscoverInTimelineContext(); + const createTimeline = useCallback( ({ id, show, timeRange: timeRangeParam }) => { const timerange = timeRangeParam ?? globalTimeRange; @@ -110,8 +113,9 @@ export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: P if (typeof closeGearMenu === 'function') { closeGearMenu(); } + resetDiscoverAppState(); }, - [createTimeline, timelineId, timelineType, closeGearMenu] + [createTimeline, timelineId, timelineType, closeGearMenu, resetDiscoverAppState] ); return handleCreateNewTimeline; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index 2707bf7b04ebc..b7b4e92afcb6f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -234,7 +234,7 @@ const ActiveTimelineTab = memo( $isVisible={TimelineTabs.discover === activeTimelineTab} data-test-subj={`timeline-tab-content-${TimelineTabs.discover}`} > - + )} diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts index 09bdaeae29b76..cfac0b1d4b243 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts @@ -82,6 +82,7 @@ const timelineData = { }, ], status: TimelineStatus.active, + savedSearchId: null, }; const mockPatchTimelineResponse = { data: { diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index 2b9dbb45d526f..dee16b6088c49 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -271,3 +271,13 @@ export const clearEventsDeleted = actionCreator<{ export const updateTotalCount = actionCreator<{ id: string; totalCount: number }>( 'UPDATE_TOTAL_COUNT' ); + +export const updateSavedSearchId = actionCreator<{ + id: string; + savedSearchId: string; +}>('UPDATE_DISCOVER_SAVED_SEARCH_ID'); + +export const setIsDiscoverSavedSearchLoaded = actionCreator<{ + id: string; + isDiscoverSavedSearchLoaded: boolean; +}>('SET_IS_DISCOVER_SAVED_SEARCH_LOADED'); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts index 23e8033a111f9..449a2aa2b13f4 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts @@ -78,6 +78,8 @@ export const timelineDefaults: SubsetTimelineModel & selectedEventIds: {}, isSelectAllChecked: false, filters: [], + savedSearchId: null, + isDiscoverSavedSearchLoaded: false, }; export const getTimelineManageDefaults = (id: string) => ({ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts index 527ea01903279..21551dacebc66 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts @@ -175,6 +175,7 @@ describe('Epic Timeline', () => { version: 'WzM4LDFd', id: '11169110-fc22-11e9-8ca9-072f15ce2685', savedQueryId: 'my endgame timeline query', + savedSearchId: null, }; expect( @@ -309,6 +310,7 @@ describe('Epic Timeline', () => { }, }, savedQueryId: 'my endgame timeline query', + savedSearchId: null, sort: [ { columnId: '@timestamp', diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts index 1cc2b9be725c2..1f71ecd67582e 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts @@ -83,6 +83,7 @@ import { addTimeline, showCallOutUnauthorizedMsg, saveTimeline, + updateSavedSearchId, } from './actions'; import type { TimelineModel } from './model'; import { epicPersistNote, timelineNoteActionsType } from './epic_note'; @@ -118,6 +119,8 @@ const timelineActionsType = [ updateSort.type, updateRange.type, upsertColumn.type, + + updateSavedSearchId.type, ]; const isItAtimelineAction = (timelineId: string | undefined) => @@ -346,6 +349,7 @@ const timelineInput: TimelineInput = { savedQueryId: null, sort: null, status: null, + savedSearchId: null, }; export const convertTimelineAsInput = ( diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index 34cf8b6dcaadf..98e50a11aa734 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -133,6 +133,9 @@ export interface TimelineModel { isSelectAllChecked: boolean; isLoading: boolean; selectAll: boolean; + /* discover saved search Id */ + savedSearchId: string | null; + isDiscoverSavedSearchLoaded?: boolean; } export type SubsetTimelineModel = Readonly< @@ -186,6 +189,8 @@ export type SubsetTimelineModel = Readonly< | 'status' | 'filters' | 'filterManager' + | 'savedSearchId' + | 'isDiscoverSavedSearchLoaded' > >; @@ -194,4 +199,5 @@ export interface TimelineUrl { id?: string; isOpen: boolean; graphEventId?: string; + savedSearchId?: string; } diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index 753b2cf4e0b16..980573f0c73c3 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -137,6 +137,7 @@ const basicTimeline: TimelineModel = { timelineType: TimelineType.default, title: '', version: null, + savedSearchId: null, }; const timelineByIdMock: TimelineById = { foo: { ...basicTimeline }, @@ -223,6 +224,7 @@ describe('Timeline', () => { indexNames: [], timelineById: timelineByIdMock, timelineType: TimelineType.default, + savedSearchId: null, }); expect(update).not.toBe(timelineByIdMock); }); @@ -235,6 +237,7 @@ describe('Timeline', () => { indexNames: [], timelineById: timelineByIdMock, timelineType: TimelineType.default, + savedSearchId: null, }); expect(update).toEqual({ foo: basicTimeline, @@ -253,6 +256,7 @@ describe('Timeline', () => { indexNames: [], timelineById: timelineByIdMock, timelineType: TimelineType.default, + savedSearchId: null, }); expect(update).toEqual({ foo: basicTimeline, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index e661fadf428d2..704017a10a7ca 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -59,6 +59,8 @@ import { applyDeltaToColumnWidth, clearEventsDeleted, clearEventsLoading, + updateSavedSearchId, + setIsDiscoverSavedSearchLoaded, } from './actions'; import { @@ -530,4 +532,24 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) }, }, })) + .case(updateSavedSearchId, (state, { id, savedSearchId }) => ({ + ...state, + timelineById: { + ...state.timelineById, + [id]: { + ...state.timelineById[id], + savedSearchId, + }, + }, + })) + .case(setIsDiscoverSavedSearchLoaded, (state, { id, isDiscoverSavedSearchLoaded }) => ({ + ...state, + timelineById: { + ...state.timelineById, + [id]: { + ...state.timelineById[id], + isDiscoverSavedSearchLoaded, + }, + }, + })) .build(); diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 6b16958f65808..f13fcc6e2877a 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -55,6 +55,7 @@ import type { DiscoverStart } from '@kbn/discover-plugin/public'; import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { UpsellingService } from '@kbn/security-solution-upselling/service'; +import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public'; import type { ResolverPluginSetup } from './resolver/types'; import type { Inspect } from '../common/search_strategy'; import type { Detections } from './detections'; @@ -130,6 +131,7 @@ export interface StartPlugins { discover: DiscoverStart; navigation: NavigationPublicPluginStart; dataViewEditor: DataViewEditorStart; + savedSearch: SavedSearchPublicPluginStart; } export interface StartPluginsDependencies extends StartPlugins { diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/serverless/es_serverless_resources/roles.yml b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/serverless/es_serverless_resources/roles.yml index 005f6e968ffc4..d216adc602c1a 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/serverless/es_serverless_resources/roles.yml +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/serverless/es_serverless_resources/roles.yml @@ -1,4 +1,17 @@ --- +system_indices_superuser: + cluster: ['all'] + indices: + - names: ['*'] + privileges: ['all'] + allow_restricted_indices: true + applications: + - application: '*' + privileges: ['*'] + resources: ['*'] + run_as: ['*'] + + #-------------------------------------------------------------------------------------------------- # # FILE SOURCE AT: diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts index b91bfae4eb405..db45f29e1bb88 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts @@ -574,7 +574,7 @@ describe('ingest_integration tests ', () => { const infoResponse = { cluster_name: 'updated-name', cluster_uuid: 'updated-uuid', - license_uid: 'updated-uid', + license_uuid: 'updated-uuid', name: 'name', tagline: 'tagline', version: { @@ -602,7 +602,7 @@ describe('ingest_integration tests ', () => { mockPolicy.meta.license = 'platinum'; // license is set to emit platinum mockPolicy.meta.cluster_name = 'updated-name'; mockPolicy.meta.cluster_uuid = 'updated-uuid'; - mockPolicy.meta.license_uid = 'updated-uid'; + mockPolicy.meta.license_uuid = 'updated-uid'; mockPolicy.meta.serverless = false; const logger = loggingSystemMock.create().get('ingest_integration.test'); const callback = getPackagePolicyUpdateCallback( @@ -621,7 +621,7 @@ describe('ingest_integration tests ', () => { policyConfig.inputs[0]!.config!.policy.value.meta.license = 'gold'; policyConfig.inputs[0]!.config!.policy.value.meta.cluster_name = 'original-name'; policyConfig.inputs[0]!.config!.policy.value.meta.cluster_uuid = 'original-uuid'; - policyConfig.inputs[0]!.config!.policy.value.meta.license_uid = 'original-uid'; + policyConfig.inputs[0]!.config!.policy.value.meta.license_uuid = 'original-uid'; policyConfig.inputs[0]!.config!.policy.value.meta.serverless = true; const updatedPolicyConfig = await callback( policyConfig, @@ -639,7 +639,7 @@ describe('ingest_integration tests ', () => { mockPolicy.meta.license = 'platinum'; // license is set to emit platinum mockPolicy.meta.cluster_name = 'updated-name'; mockPolicy.meta.cluster_uuid = 'updated-uuid'; - mockPolicy.meta.license_uid = 'updated-uid'; + mockPolicy.meta.license_uuid = 'updated-uid'; mockPolicy.meta.serverless = false; const logger = loggingSystemMock.create().get('ingest_integration.test'); const callback = getPackagePolicyUpdateCallback( @@ -657,7 +657,7 @@ describe('ingest_integration tests ', () => { policyConfig.inputs[0]!.config!.policy.value.meta.license = 'platinum'; policyConfig.inputs[0]!.config!.policy.value.meta.cluster_name = 'updated-name'; policyConfig.inputs[0]!.config!.policy.value.meta.cluster_uuid = 'updated-uuid'; - policyConfig.inputs[0]!.config!.policy.value.meta.license_uid = 'updated-uid'; + policyConfig.inputs[0]!.config!.policy.value.meta.license_uuid = 'updated-uid'; policyConfig.inputs[0]!.config!.policy.value.meta.serverless = false; const updatedPolicyConfig = await callback( policyConfig, diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts index ce6ff450a6869..23da392b96eb0 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts @@ -59,7 +59,7 @@ const shouldUpdateMetaValues = ( currentCloudInfo: boolean, currentClusterName: string, currentClusterUUID: string, - currentLicenseUID: string, + currentLicenseUUID: string, currentIsServerlessEnabled: boolean ) => { return ( @@ -67,7 +67,7 @@ const shouldUpdateMetaValues = ( endpointPackagePolicy.meta.cloud !== currentCloudInfo || endpointPackagePolicy.meta.cluster_name !== currentClusterName || endpointPackagePolicy.meta.cluster_uuid !== currentClusterUUID || - endpointPackagePolicy.meta.license_uid !== currentLicenseUID || + endpointPackagePolicy.meta.license_uuid !== currentLicenseUUID || endpointPackagePolicy.meta.serverless !== currentIsServerlessEnabled ); }; @@ -238,7 +238,7 @@ export const getPackagePolicyUpdateCallback = ( newEndpointPackagePolicy.meta.cloud = cloud?.isCloudEnabled; newEndpointPackagePolicy.meta.cluster_name = esClientInfo.cluster_name; newEndpointPackagePolicy.meta.cluster_uuid = esClientInfo.cluster_uuid; - newEndpointPackagePolicy.meta.license_uid = licenseService.getLicenseUID(); + newEndpointPackagePolicy.meta.license_uuid = licenseService.getLicenseUID(); newEndpointPackagePolicy.meta.serverless = cloud?.isServerlessEnabled; endpointIntegrationData.inputs[0].config.policy.value = newEndpointPackagePolicy; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_disable_route.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_disable_route.ts index 480f23139a9ef..b5ae6287c40fd 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_disable_route.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_disable_route.ts @@ -16,15 +16,15 @@ export const riskEngineDisableRoute = ( router: SecuritySolutionPluginRouter, getStartServices: StartServicesAccessor ) => { - router.post( - { + router.versioned + .post({ + access: 'internal', path: RISK_ENGINE_DISABLE_URL, - validate: {}, options: { tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], }, - }, - async (context, request, response) => { + }) + .addVersion({ version: '1', validate: {} }, async (context, request, response) => { const siemResponse = buildSiemResponse(response); const [_, { taskManager }] = await getStartServices(); @@ -52,6 +52,5 @@ export const riskEngineDisableRoute = ( body: { message: error.message, full_error: JSON.stringify(e) }, }); } - } - ); + }); }; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_enable_route.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_enable_route.ts index 311218213b440..af5eb77374bba 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_enable_route.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_enable_route.ts @@ -16,15 +16,15 @@ export const riskEngineEnableRoute = ( router: SecuritySolutionPluginRouter, getStartServices: StartServicesAccessor ) => { - router.post( - { + router.versioned + .post({ + access: 'internal', path: RISK_ENGINE_ENABLE_URL, - validate: {}, options: { tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], }, - }, - async (context, request, response) => { + }) + .addVersion({ version: '1', validate: {} }, async (context, request, response) => { const siemResponse = buildSiemResponse(response); const [_, { taskManager }] = await getStartServices(); const securitySolution = await context.securitySolution; @@ -51,6 +51,5 @@ export const riskEngineEnableRoute = ( body: { message: error.message, full_error: JSON.stringify(e) }, }); } - } - ); + }); }; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_init_route.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_init_route.ts index 5dc83521bc0d6..2a0a5fafc70b0 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_init_route.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_init_route.ts @@ -17,15 +17,15 @@ export const riskEngineInitRoute = ( router: SecuritySolutionPluginRouter, getStartServices: StartServicesAccessor ) => { - router.post( - { + router.versioned + .post({ + access: 'internal', path: RISK_ENGINE_INIT_URL, - validate: {}, options: { tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], }, - }, - async (context, request, response) => { + }) + .addVersion({ version: '1', validate: {} }, async (context, request, response) => { const siemResponse = buildSiemResponse(response); const securitySolution = await context.securitySolution; const [_, { taskManager }] = await getStartServices(); @@ -78,6 +78,5 @@ export const riskEngineInitRoute = ( body: { message: error.message, full_error: JSON.stringify(e) }, }); } - } - ); + }); }; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_status_route.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_status_route.ts index 84d788eb8813c..d741ee5dd23ff 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_status_route.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_status_route.ts @@ -12,15 +12,15 @@ import { RISK_ENGINE_STATUS_URL, APP_ID } from '../../../../common/constants'; import type { SecuritySolutionPluginRouter } from '../../../types'; export const riskEngineStatusRoute = (router: SecuritySolutionPluginRouter) => { - router.get( - { + router.versioned + .get({ + access: 'internal', path: RISK_ENGINE_STATUS_URL, - validate: {}, options: { tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], }, - }, - async (context, request, response) => { + }) + .addVersion({ version: '1', validate: {} }, async (context, request, response) => { const siemResponse = buildSiemResponse(response); const securitySolution = await context.securitySolution; @@ -46,6 +46,5 @@ export const riskEngineStatusRoute = (router: SecuritySolutionPluginRouter) => { body: { message: error.message, full_error: JSON.stringify(e) }, }); } - } - ); + }); }; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_score_calculation_route.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_score_calculation_route.ts index 31d61b2aa36b4..75c88678fd070 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_score_calculation_route.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_score_calculation_route.ts @@ -9,6 +9,7 @@ import type { Logger } from '@kbn/core/server'; import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; import { transformError } from '@kbn/securitysolution-es-utils'; import { + APP_ID, DEFAULT_RISK_SCORE_PAGE_SIZE, RISK_SCORE_CALCULATION_URL, } from '../../../../common/constants'; @@ -19,72 +20,77 @@ import { riskScoreServiceFactory } from '../risk_score_service'; import { getRiskInputsIndex } from '../get_risk_inputs_index'; export const riskScoreCalculationRoute = (router: SecuritySolutionPluginRouter, logger: Logger) => { - router.post( - { + router.versioned + .post({ path: RISK_SCORE_CALCULATION_URL, - validate: { body: buildRouteValidation(riskScoreCalculationRequestSchema) }, + access: 'public', options: { - tags: ['access:securitySolution'], + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], }, - }, - async (context, request, response) => { - const siemResponse = buildSiemResponse(response); - const securityContext = await context.securitySolution; - const coreContext = await context.core; - const esClient = coreContext.elasticsearch.client.asCurrentUser; - const soClient = coreContext.savedObjects.client; - const spaceId = securityContext.getSpaceId(); - const riskEngineDataClient = securityContext.getRiskEngineDataClient(); - - const riskScoreService = riskScoreServiceFactory({ - esClient, - logger, - riskEngineDataClient, - spaceId, - }); - - const { - after_keys: userAfterKeys, - data_view_id: dataViewId, - debug, - page_size: userPageSize, - identifier_type: identifierType, - filter, - range, - weights, - } = request.body; + }) + .addVersion( + { + version: '2023-10-31', + validate: { request: { body: buildRouteValidation(riskScoreCalculationRequestSchema) } }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + const securityContext = await context.securitySolution; + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asCurrentUser; + const soClient = coreContext.savedObjects.client; + const spaceId = securityContext.getSpaceId(); + const riskEngineDataClient = securityContext.getRiskEngineDataClient(); - try { - const { index, runtimeMappings } = await getRiskInputsIndex({ - dataViewId, + const riskScoreService = riskScoreServiceFactory({ + esClient, logger, - soClient, + riskEngineDataClient, + spaceId, }); - const afterKeys = userAfterKeys ?? {}; - const pageSize = userPageSize ?? DEFAULT_RISK_SCORE_PAGE_SIZE; - - const result = await riskScoreService.calculateAndPersistScores({ - afterKeys, + const { + after_keys: userAfterKeys, + data_view_id: dataViewId, debug, - pageSize, - identifierType, - index, + page_size: userPageSize, + identifier_type: identifierType, filter, range, - runtimeMappings, weights, - }); + } = request.body; - return response.ok({ body: result }); - } catch (e) { - const error = transformError(e); + try { + const { index, runtimeMappings } = await getRiskInputsIndex({ + dataViewId, + logger, + soClient, + }); - return siemResponse.error({ - statusCode: error.statusCode, - body: { message: error.message, full_error: JSON.stringify(e) }, - }); + const afterKeys = userAfterKeys ?? {}; + const pageSize = userPageSize ?? DEFAULT_RISK_SCORE_PAGE_SIZE; + + const result = await riskScoreService.calculateAndPersistScores({ + afterKeys, + debug, + pageSize, + identifierType, + index, + filter, + range, + runtimeMappings, + weights, + }); + + return response.ok({ body: result }); + } catch (e) { + const error = transformError(e); + + return siemResponse.error({ + statusCode: error.statusCode, + body: { message: error.message, full_error: JSON.stringify(e) }, + }); + } } - } - ); + ); }; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_score_preview_route.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_score_preview_route.ts index 224ccee95b60c..9a1a5f2b83d7c 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_score_preview_route.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_score_preview_route.ts @@ -9,7 +9,11 @@ import type { Logger } from '@kbn/core/server'; import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; import { transformError } from '@kbn/securitysolution-es-utils'; -import { DEFAULT_RISK_SCORE_PAGE_SIZE, RISK_SCORE_PREVIEW_URL } from '../../../../common/constants'; +import { + APP_ID, + DEFAULT_RISK_SCORE_PAGE_SIZE, + RISK_SCORE_PREVIEW_URL, +} from '../../../../common/constants'; import { riskScorePreviewRequestSchema } from '../../../../common/risk_engine/risk_score_preview/request_schema'; import type { SecuritySolutionPluginRouter } from '../../../types'; import { buildRouteValidation } from '../../../utils/build_validation/route_validation'; @@ -17,73 +21,78 @@ import { riskScoreServiceFactory } from '../risk_score_service'; import { getRiskInputsIndex } from '../get_risk_inputs_index'; export const riskScorePreviewRoute = (router: SecuritySolutionPluginRouter, logger: Logger) => { - router.post( - { + router.versioned + .post({ + access: 'internal', path: RISK_SCORE_PREVIEW_URL, - validate: { body: buildRouteValidation(riskScorePreviewRequestSchema) }, options: { - tags: ['access:securitySolution'], + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], }, - }, - async (context, request, response) => { - const siemResponse = buildSiemResponse(response); - const securityContext = await context.securitySolution; - const coreContext = await context.core; - const esClient = coreContext.elasticsearch.client.asCurrentUser; - const soClient = coreContext.savedObjects.client; - const spaceId = securityContext.getSpaceId(); - const riskEngineDataClient = securityContext.getRiskEngineDataClient(); - - const riskScoreService = riskScoreServiceFactory({ - esClient, - logger, - riskEngineDataClient, - spaceId, - }); - - const { - after_keys: userAfterKeys, - data_view_id: dataViewId, - debug, - page_size: userPageSize, - identifier_type: identifierType, - filter, - range: userRange, - weights, - } = request.body; + }) + .addVersion( + { + version: '1', + validate: { request: { body: buildRouteValidation(riskScorePreviewRequestSchema) } }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + const securityContext = await context.securitySolution; + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asCurrentUser; + const soClient = coreContext.savedObjects.client; + const spaceId = securityContext.getSpaceId(); + const riskEngineDataClient = securityContext.getRiskEngineDataClient(); - try { - const { index, runtimeMappings } = await getRiskInputsIndex({ - dataViewId, + const riskScoreService = riskScoreServiceFactory({ + esClient, logger, - soClient, + riskEngineDataClient, + spaceId, }); - const afterKeys = userAfterKeys ?? {}; - const range = userRange ?? { start: 'now-15d', end: 'now' }; - const pageSize = userPageSize ?? DEFAULT_RISK_SCORE_PAGE_SIZE; - - const result = await riskScoreService.calculateScores({ - afterKeys, + const { + after_keys: userAfterKeys, + data_view_id: dataViewId, debug, + page_size: userPageSize, + identifier_type: identifierType, filter, - identifierType, - index, - pageSize, - range, - runtimeMappings, + range: userRange, weights, - }); + } = request.body; - return response.ok({ body: result }); - } catch (e) { - const error = transformError(e); + try { + const { index, runtimeMappings } = await getRiskInputsIndex({ + dataViewId, + logger, + soClient, + }); - return siemResponse.error({ - statusCode: error.statusCode, - body: { message: error.message, full_error: JSON.stringify(e) }, - }); + const afterKeys = userAfterKeys ?? {}; + const range = userRange ?? { start: 'now-15d', end: 'now' }; + const pageSize = userPageSize ?? DEFAULT_RISK_SCORE_PAGE_SIZE; + + const result = await riskScoreService.calculateScores({ + afterKeys, + debug, + filter, + identifierType, + index, + pageSize, + range, + runtimeMappings, + weights, + }); + + return response.ok({ body: result }); + } catch (e) { + const error = transformError(e); + + return siemResponse.error({ + statusCode: error.statusCode, + body: { message: error.message, full_error: JSON.stringify(e) }, + }); + } } - } - ); + ); }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/convert_saved_object_to_savedtimeline.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/convert_saved_object_to_savedtimeline.ts index f93d8a5fef73c..60d2e1cdca502 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/convert_saved_object_to_savedtimeline.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/convert_saved_object_to_savedtimeline.ts @@ -81,6 +81,7 @@ export const convertSavedObjectToSavedTimeline = (savedObject: unknown): Timelin ? savedTimeline.attributes.sort : [savedTimeline.attributes.sort] : [], + savedSearchId: savedTimeline.attributes.savedSearchId, }; return { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/timelines.ts index d133e9b114f8b..bc4fdfb1f79bf 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/timelines.ts @@ -316,6 +316,9 @@ export const timelineSavedObjectMappings: SavedObjectsType['mappings'] = { updatedBy: { type: 'text', }, + savedSearchId: { + type: 'text', + }, }, }; diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 3dbc778394ecb..0eea99624d611 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -171,6 +171,7 @@ "@kbn/core-lifecycle-browser", "@kbn/security-solution-features", "@kbn/content-management-plugin", + "@kbn/discover-utils", "@kbn/subscription-tracking", "@kbn/openapi-generator" ] diff --git a/x-pack/plugins/synthetics/common/requests/get_certs_request_body.ts b/x-pack/plugins/synthetics/common/requests/get_certs_request_body.ts index 4b0e92a6b9959..f14137e4f77b3 100644 --- a/x-pack/plugins/synthetics/common/requests/get_certs_request_body.ts +++ b/x-pack/plugins/synthetics/common/requests/get_certs_request_body.ts @@ -7,6 +7,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import DateMath from '@kbn/datemath'; +import { EXCLUDE_RUN_ONCE_FILTER, SUMMARY_FILTER } from '../constants/client_defaults'; import type { CertificatesResults } from '../../server/queries/get_certs'; import { CertResult, GetCertsParams, Ping } from '../runtime_types'; import { createEsQuery } from '../utils/es_search'; @@ -79,6 +80,8 @@ export const getCertsRequestBody = ({ } : {}), filter: [ + SUMMARY_FILTER, + EXCLUDE_RUN_ONCE_FILTER, ...(filters ? [filters] : []), ...(monitorIds && monitorIds.length > 0 ? [{ terms: { 'monitor.id': monitorIds } }] diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 16b73a256c716..59dbc72ac62b0 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -18741,9 +18741,7 @@ "xpack.infra.hostsViewPage.tabs.metricsCharts.tx": "Réseau sortant (TX)", "xpack.infra.hostsViewPage.tooltip.whatAreTheseMetricsLink": "Que sont ces indicateurs ?", "xpack.infra.infra.nodeDetails.apmTabLabel": "APM", - "xpack.infra.infra.nodeDetails.createAlertLink": "Créer une règle d'inventaire", "xpack.infra.infra.nodeDetails.openAsPage": "Ouvrir en tant que page", - "xpack.infra.infra.nodeDetails.updtimeTabLabel": "Uptime", "xpack.infra.inventory.alerting.groupActionVariableDescription": "Nom des données de reporting du groupe", "xpack.infra.inventoryModel.container.displayName": "Conteneurs Docker", "xpack.infra.inventoryModel.container.singularDisplayName": "Conteneur Docker", @@ -19233,7 +19231,6 @@ "xpack.infra.metrics.nodeDetails.processes.stateZombie": "Zombie", "xpack.infra.metrics.nodeDetails.processes.viewTraceInAPM": "Afficher la trace dans APM", "xpack.infra.metrics.nodeDetails.processesHeader": "Processus principaux", - "xpack.infra.metrics.nodeDetails.processesHeader.tooltipBody": "Le tableau ci-dessous agrège les principaux processus de consommation de CPU et de mémoire. Il n'affiche pas tous les processus.", "xpack.infra.metrics.nodeDetails.processesHeader.tooltipLabel": "Plus d'infos", "xpack.infra.metrics.nodeDetails.processListError": "Impossible de charger les données de processus", "xpack.infra.metrics.nodeDetails.processListRetry": "Réessayer", @@ -19381,41 +19378,16 @@ "xpack.infra.nodeDetails.labels.showMoreDetails": "Afficher plus de détails", "xpack.infra.nodeDetails.logs.openLogsLink": "Ouvrir dans Logs", "xpack.infra.nodeDetails.logs.textFieldPlaceholder": "Rechercher les entrées de logs...", - "xpack.infra.nodeDetails.metrics.cached": "En cache", - "xpack.infra.nodeDetails.metrics.charts.loadTitle": "Charge", - "xpack.infra.nodeDetails.metrics.charts.logRateTitle": "Taux de log", - "xpack.infra.nodeDetails.metrics.charts.memoryTitle": "Mémoire", - "xpack.infra.nodeDetails.metrics.charts.networkTitle": "Réseau", - "xpack.infra.nodeDetails.metrics.fcharts.cpuTitle": "CPU", - "xpack.infra.nodeDetails.metrics.free": "Gratuit", - "xpack.infra.nodeDetails.metrics.inbound": "Entrant", - "xpack.infra.nodeDetails.metrics.last15Minutes": "15 dernières minutes", - "xpack.infra.nodeDetails.metrics.last24Hours": "Dernières 24 heures", - "xpack.infra.nodeDetails.metrics.last3Hours": "3 dernières heures", - "xpack.infra.nodeDetails.metrics.last7Days": "7 derniers jours", - "xpack.infra.nodeDetails.metrics.lastHour": "Dernière heure", - "xpack.infra.nodeDetails.metrics.logRate": "Taux de log", - "xpack.infra.nodeDetails.metrics.outbound": "Sortant", - "xpack.infra.nodeDetails.metrics.system": "Système", - "xpack.infra.nodeDetails.metrics.used": "Utilisé", - "xpack.infra.nodeDetails.metrics.user": "Utilisateur", "xpack.infra.nodeDetails.no": "Non", "xpack.infra.nodeDetails.tabs.anomalies": "Anomalies", "xpack.infra.nodeDetails.tabs.logs": "Logs", "xpack.infra.nodeDetails.tabs.logs.title": "Logs", - "xpack.infra.nodeDetails.tabs.metadata.agentHeader": "Agent", - "xpack.infra.nodeDetails.tabs.metadata.cloudHeader": "Cloud", - "xpack.infra.nodeDetails.tabs.metadata.filterAriaLabel": "Filtre", - "xpack.infra.nodeDetails.tabs.metadata.hostsHeader": "Hôtes", "xpack.infra.nodeDetails.tabs.metadata.seeLess": "Afficher moins", - "xpack.infra.nodeDetails.tabs.metadata.setFilterTooltip": "Afficher l'événement avec filtre", "xpack.infra.nodeDetails.tabs.metadata.title": "Métadonnées", - "xpack.infra.nodeDetails.tabs.metrics": "Indicateurs", "xpack.infra.nodeDetails.tabs.osquery": "Osquery", "xpack.infra.nodeDetails.yes": "Oui", "xpack.infra.nodesToWaffleMap.groupsWithGroups.allName": "Tous", "xpack.infra.nodesToWaffleMap.groupsWithNodes.allName": "Tous", - "xpack.infra.notAvailableLabel": "S. O.", "xpack.infra.openView.actionNames.deleteConfirmation": "Supprimer la vue ?", "xpack.infra.openView.cancelButton": "Annuler", "xpack.infra.openView.columnNames.actions": "Actions", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 97141ea4edb75..8174504c2c605 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -18755,9 +18755,7 @@ "xpack.infra.hostsViewPage.tabs.metricsCharts.tx": "ネットワーク送信(TX)", "xpack.infra.hostsViewPage.tooltip.whatAreTheseMetricsLink": "これらのメトリックは何か。", "xpack.infra.infra.nodeDetails.apmTabLabel": "APM", - "xpack.infra.infra.nodeDetails.createAlertLink": "インベントリルールの作成", "xpack.infra.infra.nodeDetails.openAsPage": "ページとして開く", - "xpack.infra.infra.nodeDetails.updtimeTabLabel": "アップタイム", "xpack.infra.inventory.alerting.groupActionVariableDescription": "データを報告するグループの名前", "xpack.infra.inventoryModel.container.displayName": "Dockerコンテナー", "xpack.infra.inventoryModel.container.singularDisplayName": "Docker コンテナー", @@ -19247,7 +19245,6 @@ "xpack.infra.metrics.nodeDetails.processes.stateZombie": "ゾンビ", "xpack.infra.metrics.nodeDetails.processes.viewTraceInAPM": "APM でトレースを表示", "xpack.infra.metrics.nodeDetails.processesHeader": "上位のプロセス", - "xpack.infra.metrics.nodeDetails.processesHeader.tooltipBody": "次の表は、上位の CPU および上位のメモリ消費プロセスの集計です。一部のプロセスは表示されません。", "xpack.infra.metrics.nodeDetails.processesHeader.tooltipLabel": "詳細", "xpack.infra.metrics.nodeDetails.processListError": "プロセスデータを読み込めません", "xpack.infra.metrics.nodeDetails.processListRetry": "再試行", @@ -19395,41 +19392,16 @@ "xpack.infra.nodeDetails.labels.showMoreDetails": "他の詳細を表示", "xpack.infra.nodeDetails.logs.openLogsLink": "ログで開く", "xpack.infra.nodeDetails.logs.textFieldPlaceholder": "ログエントリーを検索...", - "xpack.infra.nodeDetails.metrics.cached": "キャッシュ", - "xpack.infra.nodeDetails.metrics.charts.loadTitle": "読み込み", - "xpack.infra.nodeDetails.metrics.charts.logRateTitle": "ログレート", - "xpack.infra.nodeDetails.metrics.charts.memoryTitle": "メモリー", - "xpack.infra.nodeDetails.metrics.charts.networkTitle": "ネットワーク", - "xpack.infra.nodeDetails.metrics.fcharts.cpuTitle": "CPU", - "xpack.infra.nodeDetails.metrics.free": "空き", - "xpack.infra.nodeDetails.metrics.inbound": "受信", - "xpack.infra.nodeDetails.metrics.last15Minutes": "過去15分間", - "xpack.infra.nodeDetails.metrics.last24Hours": "過去 24 時間", - "xpack.infra.nodeDetails.metrics.last3Hours": "過去 3 時間", - "xpack.infra.nodeDetails.metrics.last7Days": "過去 7 日間", - "xpack.infra.nodeDetails.metrics.lastHour": "過去 1 時間", - "xpack.infra.nodeDetails.metrics.logRate": "ログレート", - "xpack.infra.nodeDetails.metrics.outbound": "送信", - "xpack.infra.nodeDetails.metrics.system": "システム", - "xpack.infra.nodeDetails.metrics.used": "使用中", - "xpack.infra.nodeDetails.metrics.user": "ユーザー", "xpack.infra.nodeDetails.no": "いいえ", "xpack.infra.nodeDetails.tabs.anomalies": "異常", "xpack.infra.nodeDetails.tabs.logs": "ログ", "xpack.infra.nodeDetails.tabs.logs.title": "ログ", - "xpack.infra.nodeDetails.tabs.metadata.agentHeader": "エージェント", - "xpack.infra.nodeDetails.tabs.metadata.cloudHeader": "クラウド", - "xpack.infra.nodeDetails.tabs.metadata.filterAriaLabel": "フィルター", - "xpack.infra.nodeDetails.tabs.metadata.hostsHeader": "ホスト", "xpack.infra.nodeDetails.tabs.metadata.seeLess": "簡易表示", - "xpack.infra.nodeDetails.tabs.metadata.setFilterTooltip": "フィルターでイベントを表示", "xpack.infra.nodeDetails.tabs.metadata.title": "メタデータ", - "xpack.infra.nodeDetails.tabs.metrics": "メトリック", "xpack.infra.nodeDetails.tabs.osquery": "Osquery", "xpack.infra.nodeDetails.yes": "はい", "xpack.infra.nodesToWaffleMap.groupsWithGroups.allName": "すべて", "xpack.infra.nodesToWaffleMap.groupsWithNodes.allName": "すべて", - "xpack.infra.notAvailableLabel": "N/A", "xpack.infra.openView.actionNames.deleteConfirmation": "ビューを削除しますか?", "xpack.infra.openView.cancelButton": "キャンセル", "xpack.infra.openView.columnNames.actions": "アクション", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cd9abd4c6b869..44791093b7bd5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -18755,9 +18755,7 @@ "xpack.infra.hostsViewPage.tabs.metricsCharts.tx": "网络出站数据 (TX)", "xpack.infra.hostsViewPage.tooltip.whatAreTheseMetricsLink": "这些指标是什么?", "xpack.infra.infra.nodeDetails.apmTabLabel": "APM", - "xpack.infra.infra.nodeDetails.createAlertLink": "创建库存规则", "xpack.infra.infra.nodeDetails.openAsPage": "以页面形式打开", - "xpack.infra.infra.nodeDetails.updtimeTabLabel": "运行时间", "xpack.infra.inventory.alerting.groupActionVariableDescription": "报告数据的组名称", "xpack.infra.inventoryModel.container.displayName": "Docker 容器", "xpack.infra.inventoryModel.container.singularDisplayName": "Docker 容器", @@ -19247,7 +19245,6 @@ "xpack.infra.metrics.nodeDetails.processes.stateZombie": "僵停", "xpack.infra.metrics.nodeDetails.processes.viewTraceInAPM": "在 APM 中查看跟踪", "xpack.infra.metrics.nodeDetails.processesHeader": "排序靠前的进程", - "xpack.infra.metrics.nodeDetails.processesHeader.tooltipBody": "下表聚合了 CPU 和内存消耗靠前的进程。不显示所有进程。", "xpack.infra.metrics.nodeDetails.processesHeader.tooltipLabel": "更多信息", "xpack.infra.metrics.nodeDetails.processListError": "无法加载进程数据", "xpack.infra.metrics.nodeDetails.processListRetry": "重试", @@ -19395,41 +19392,16 @@ "xpack.infra.nodeDetails.labels.showMoreDetails": "显示更多详情", "xpack.infra.nodeDetails.logs.openLogsLink": "在日志中打开", "xpack.infra.nodeDetails.logs.textFieldPlaceholder": "搜索日志条目......", - "xpack.infra.nodeDetails.metrics.cached": "已缓存", - "xpack.infra.nodeDetails.metrics.charts.loadTitle": "加载", - "xpack.infra.nodeDetails.metrics.charts.logRateTitle": "日志速率", - "xpack.infra.nodeDetails.metrics.charts.memoryTitle": "内存", - "xpack.infra.nodeDetails.metrics.charts.networkTitle": "网络", - "xpack.infra.nodeDetails.metrics.fcharts.cpuTitle": "CPU", - "xpack.infra.nodeDetails.metrics.free": "可用", - "xpack.infra.nodeDetails.metrics.inbound": "入站", - "xpack.infra.nodeDetails.metrics.last15Minutes": "过去 15 分钟", - "xpack.infra.nodeDetails.metrics.last24Hours": "过去 24 小时", - "xpack.infra.nodeDetails.metrics.last3Hours": "过去 3 小时", - "xpack.infra.nodeDetails.metrics.last7Days": "过去 7 天", - "xpack.infra.nodeDetails.metrics.lastHour": "过去一小时", - "xpack.infra.nodeDetails.metrics.logRate": "日志速率", - "xpack.infra.nodeDetails.metrics.outbound": "出站", - "xpack.infra.nodeDetails.metrics.system": "系统", - "xpack.infra.nodeDetails.metrics.used": "已使用", - "xpack.infra.nodeDetails.metrics.user": "用户", "xpack.infra.nodeDetails.no": "否", "xpack.infra.nodeDetails.tabs.anomalies": "异常", "xpack.infra.nodeDetails.tabs.logs": "日志", "xpack.infra.nodeDetails.tabs.logs.title": "日志", - "xpack.infra.nodeDetails.tabs.metadata.agentHeader": "代理", - "xpack.infra.nodeDetails.tabs.metadata.cloudHeader": "云", - "xpack.infra.nodeDetails.tabs.metadata.filterAriaLabel": "筛选", - "xpack.infra.nodeDetails.tabs.metadata.hostsHeader": "主机", "xpack.infra.nodeDetails.tabs.metadata.seeLess": "显示更少", - "xpack.infra.nodeDetails.tabs.metadata.setFilterTooltip": "使用筛选查看事件", "xpack.infra.nodeDetails.tabs.metadata.title": "元数据", - "xpack.infra.nodeDetails.tabs.metrics": "指标", "xpack.infra.nodeDetails.tabs.osquery": "Osquery", "xpack.infra.nodeDetails.yes": "是", "xpack.infra.nodesToWaffleMap.groupsWithGroups.allName": "全部", "xpack.infra.nodesToWaffleMap.groupsWithNodes.allName": "全部", - "xpack.infra.notAvailableLabel": "不可用", "xpack.infra.openView.actionNames.deleteConfirmation": "删除视图?", "xpack.infra.openView.cancelButton": "取消", "xpack.infra.openView.columnNames.actions": "操作", diff --git a/x-pack/plugins/uptime/common/requests/get_certs_request_body.ts b/x-pack/plugins/uptime/common/requests/get_certs_request_body.ts index 7a57f8ab13d41..84b65d9d54915 100644 --- a/x-pack/plugins/uptime/common/requests/get_certs_request_body.ts +++ b/x-pack/plugins/uptime/common/requests/get_certs_request_body.ts @@ -7,6 +7,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import DateMath from '@kbn/datemath'; +import { EXCLUDE_RUN_ONCE_FILTER, SUMMARY_FILTER } from '../constants/client_defaults'; import { CertResult, GetCertsParams, Ping } from '../runtime_types'; import { createEsQuery } from '../utils/es_search'; @@ -79,6 +80,8 @@ export const getCertsRequestBody = ({ } : {}), filter: [ + SUMMARY_FILTER, + EXCLUDE_RUN_ONCE_FILTER, ...(filters ? [filters] : []), ...(monitorIds && monitorIds.length > 0 ? [{ terms: { 'monitor.id': monitorIds } }] diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/lib.ts b/x-pack/plugins/uptime/server/legacy_uptime/lib/lib.ts index ae5ceca74fdd1..15f2530667d7d 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/lib.ts +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/lib.ts @@ -185,7 +185,7 @@ export class UptimeEsClient { const showInspectData = (isInspectorEnabled || this.isDev) && path !== API_URLS.DYNAMIC_SETTINGS; - if (showInspectData) { + if (showInspectData && this.inspectableEsQueries.length > 0) { return { _inspect: this.inspectableEsQueries }; } return {}; diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/__snapshots__/get_monitor_duration.test.ts.snap b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/__snapshots__/get_monitor_duration.test.ts.snap index 0570802954511..eb8499391abc5 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/__snapshots__/get_monitor_duration.test.ts.snap +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/__snapshots__/get_monitor_duration.test.ts.snap @@ -30,6 +30,20 @@ Array [ "query": Object { "bool": Object { "filter": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "@timestamp": Object { diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_certs.test.ts b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_certs.test.ts index f9523c09e43a1..fe6ed1fc5f4f8 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_certs.test.ts +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_certs.test.ts @@ -184,6 +184,20 @@ describe('getCerts', () => { "query": Object { "bool": Object { "filter": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "exists": Object { "field": "tls.server.hash.sha256", diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_latest_monitor.test.ts b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_latest_monitor.test.ts index f276384a86c74..8070e5142516d 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_latest_monitor.test.ts +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_latest_monitor.test.ts @@ -24,6 +24,15 @@ describe('getLatestMonitor', () => { field: 'summary', }, }, + { + bool: { + must_not: { + exists: { + field: 'run_once', + }, + }, + }, + }, { range: { '@timestamp': { diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_latest_monitor.ts b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_latest_monitor.ts index b560d64be3274..a4e69121887d1 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_latest_monitor.ts +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_latest_monitor.ts @@ -6,6 +6,10 @@ */ import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { + EXCLUDE_RUN_ONCE_FILTER, + SUMMARY_FILTER, +} from '../../../../common/constants/client_defaults'; import { UMElasticsearchQueryFn } from '../adapters'; import { Ping } from '../../../../common/runtime_types'; @@ -34,7 +38,8 @@ export const getLatestMonitor: UMElasticsearchQueryFn { "field": "summary", }, }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "@timestamp": Object { @@ -384,6 +393,15 @@ describe('monitor availability', () => { "field": "summary", }, }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "@timestamp": Object { @@ -710,6 +728,15 @@ describe('monitor availability', () => { "field": "summary", }, }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "@timestamp": Object { @@ -813,6 +840,15 @@ describe('monitor availability', () => { "field": "summary", }, }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "@timestamp": Object { @@ -940,6 +976,15 @@ describe('monitor availability', () => { "field": "summary", }, }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "@timestamp": Object { diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_availability.ts b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_availability.ts index 93816938dc887..2fde5666a44df 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_availability.ts +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_availability.ts @@ -5,6 +5,10 @@ * 2.0. */ +import { + EXCLUDE_RUN_ONCE_FILTER, + SUMMARY_FILTER, +} from '../../../../common/constants/client_defaults'; import { UMElasticsearchQueryFn } from '../adapters'; import { GetMonitorAvailabilityParams, Ping } from '../../../../common/runtime_types'; import { asMutableArray } from '../../../../common/utils/as_mutable_array'; @@ -62,11 +66,8 @@ export const getMonitorAvailability: UMElasticsearchQueryFn< query: { bool: { filter: [ - { - exists: { - field: 'summary', - }, - }, + SUMMARY_FILTER, + EXCLUDE_RUN_ONCE_FILTER, { range: { '@timestamp': { diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_duration.ts b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_duration.ts index a5bc973308381..cb0ab17f656fc 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_duration.ts +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_duration.ts @@ -6,6 +6,10 @@ */ import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { + EXCLUDE_RUN_ONCE_FILTER, + SUMMARY_FILTER, +} from '../../../../common/constants/client_defaults'; import { UMElasticsearchQueryFn } from '../adapters'; import { LocationDurationLine, MonitorDurationResult } from '../../../../common/types'; import { QUERY, UNNAMED_LOCATION } from '../../../../common/constants'; @@ -30,6 +34,8 @@ export const getMonitorDurationChart: UMElasticsearchQueryFn< query: { bool: { filter: [ + SUMMARY_FILTER, + EXCLUDE_RUN_ONCE_FILTER, { range: { '@timestamp': { gte: dateStart, lte: dateEnd } } }, { term: { 'monitor.id': monitorId } }, { range: { 'monitor.duration.us': { gt: 0 } } }, diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_locations.ts b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_locations.ts index bb06a3680b7a6..659eec8bb375a 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_locations.ts +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_locations.ts @@ -5,6 +5,10 @@ * 2.0. */ +import { + EXCLUDE_RUN_ONCE_FILTER, + SUMMARY_FILTER, +} from '../../../../common/constants/client_defaults'; import { UMElasticsearchQueryFn } from '../adapters'; import { MonitorLocations, MonitorLocation } from '../../../../common/runtime_types'; import { UNNAMED_LOCATION } from '../../../../common/constants'; @@ -43,11 +47,8 @@ export const getMonitorLocations: UMElasticsearchQueryFn< 'monitor.id': monitorId, }, }, - { - exists: { - field: 'summary', - }, - }, + SUMMARY_FILTER, + EXCLUDE_RUN_ONCE_FILTER, { range: { '@timestamp': { diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_status.test.ts b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_status.test.ts index 1bb37fbb6cf9e..2ceda7dc03ace 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_status.test.ts +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_status.test.ts @@ -150,6 +150,15 @@ describe('getMonitorStatus', () => { "field": "summary", }, }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "summary.down": Object { @@ -291,6 +300,15 @@ describe('getMonitorStatus', () => { "field": "summary", }, }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "summary.down": Object { @@ -469,6 +487,15 @@ describe('getMonitorStatus', () => { "field": "summary", }, }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "summary.down": Object { @@ -640,6 +667,15 @@ describe('getMonitorStatus', () => { "field": "summary", }, }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "summary.down": Object { @@ -786,6 +822,15 @@ describe('getMonitorStatus', () => { "field": "summary", }, }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "summary.down": Object { diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_status.ts b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_status.ts index 93d03029ee5a4..1c99d1a6dd022 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_status.ts +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_monitor_status.ts @@ -9,6 +9,10 @@ import { JsonObject } from '@kbn/utility-types'; import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { PromiseType } from 'utility-types'; import { formatDurationFromTimeUnitChar, TimeUnitChar } from '@kbn/observability-plugin/common'; +import { + EXCLUDE_RUN_ONCE_FILTER, + SUMMARY_FILTER, +} from '../../../../common/constants/client_defaults'; import { asMutableArray } from '../../../../common/utils/as_mutable_array'; import { UMElasticsearchQueryFn } from '../adapters'; import { Ping } from '../../../../common/runtime_types/ping'; @@ -91,11 +95,8 @@ const executeQueryParams = async ({ query: { bool: { filter: [ - { - exists: { - field: 'summary', - }, - }, + SUMMARY_FILTER, + EXCLUDE_RUN_ONCE_FILTER, { range: { 'summary.down': { diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_ping_histogram.ts b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_ping_histogram.ts index 339e7cc661aa3..a8dcb9e8adc4e 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_ping_histogram.ts @@ -12,7 +12,10 @@ import { QUERY } from '../../../../common/constants'; import { UMElasticsearchQueryFn } from '../adapters/framework'; import { createEsQuery } from '../../../../common/utils/es_search'; import { getHistogramInterval } from '../../../../common/lib/get_histogram_interval'; -import { EXCLUDE_RUN_ONCE_FILTER } from '../../../../common/constants/client_defaults'; +import { + EXCLUDE_RUN_ONCE_FILTER, + SUMMARY_FILTER, +} from '../../../../common/constants/client_defaults'; export const getPingHistogram: UMElasticsearchQueryFn< GetPingHistogramParams, @@ -47,15 +50,7 @@ export const getPingHistogram: UMElasticsearchQueryFn< body: { query: { bool: { - filter: [ - ...filter, - { - exists: { - field: 'summary', - }, - }, - EXCLUDE_RUN_ONCE_FILTER, - ], + filter: [...filter, SUMMARY_FILTER, EXCLUDE_RUN_ONCE_FILTER], }, }, size: 0, diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_pings.test.ts b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_pings.test.ts index 65735be5e5e64..eac84470ca190 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_pings.test.ts +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_pings.test.ts @@ -130,35 +130,25 @@ describe('getAll', () => { "bool": Object { "filter": Array [ Object { - "range": Object { - "@timestamp": Object { - "gte": "now-1h", - "lte": "now", - }, + "exists": Object { + "field": "summary", }, }, - ], - "must_not": Array [ Object { "bool": Object { - "filter": Array [ - Object { - "term": Object { - "monitor.type": "browser", - }, + "must_not": Object { + "exists": Object { + "field": "run_once", }, - Object { - "bool": Object { - "must_not": Array [ - Object { - "exists": Object { - "field": "summary", - }, - }, - ], - }, - }, - ], + }, + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-1h", + "lte": "now", + }, }, }, ], @@ -202,35 +192,25 @@ describe('getAll', () => { "bool": Object { "filter": Array [ Object { - "range": Object { - "@timestamp": Object { - "gte": "now-1h", - "lte": "now", - }, + "exists": Object { + "field": "summary", }, }, - ], - "must_not": Array [ Object { "bool": Object { - "filter": Array [ - Object { - "term": Object { - "monitor.type": "browser", - }, - }, - Object { - "bool": Object { - "must_not": Array [ - Object { - "exists": Object { - "field": "summary", - }, - }, - ], - }, + "must_not": Object { + "exists": Object { + "field": "run_once", }, - ], + }, + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-1h", + "lte": "now", + }, }, }, ], @@ -274,35 +254,25 @@ describe('getAll', () => { "bool": Object { "filter": Array [ Object { - "range": Object { - "@timestamp": Object { - "gte": "now-1h", - "lte": "now", - }, + "exists": Object { + "field": "summary", }, }, - ], - "must_not": Array [ Object { "bool": Object { - "filter": Array [ - Object { - "term": Object { - "monitor.type": "browser", - }, - }, - Object { - "bool": Object { - "must_not": Array [ - Object { - "exists": Object { - "field": "summary", - }, - }, - ], - }, + "must_not": Object { + "exists": Object { + "field": "run_once", }, - ], + }, + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-1h", + "lte": "now", + }, }, }, ], @@ -345,6 +315,20 @@ describe('getAll', () => { "query": Object { "bool": Object { "filter": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "@timestamp": Object { @@ -359,30 +343,6 @@ describe('getAll', () => { }, }, ], - "must_not": Array [ - Object { - "bool": Object { - "filter": Array [ - Object { - "term": Object { - "monitor.type": "browser", - }, - }, - Object { - "bool": Object { - "must_not": Array [ - Object { - "exists": Object { - "field": "summary", - }, - }, - ], - }, - }, - ], - }, - }, - ], }, }, "size": 25, @@ -420,15 +380,11 @@ describe('getAll', () => { expect(mockEsClient.search.mock.calls[0][0].body.query.bool.filter[1]).toMatchInlineSnapshot(` Object { "bool": Object { - "must_not": Array [ - Object { - "terms": Object { - "observer.geo.name": Array [ - "fairbanks", - ], - }, + "must_not": Object { + "exists": Object { + "field": "run_once", }, - ], + }, }, } `); @@ -467,6 +423,20 @@ describe('getAll', () => { "query": Object { "bool": Object { "filter": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + Object { + "bool": Object { + "must_not": Object { + "exists": Object { + "field": "run_once", + }, + }, + }, + }, Object { "range": Object { "@timestamp": Object { @@ -481,30 +451,6 @@ describe('getAll', () => { }, }, ], - "must_not": Array [ - Object { - "bool": Object { - "filter": Array [ - Object { - "term": Object { - "monitor.type": "browser", - }, - }, - Object { - "bool": Object { - "must_not": Array [ - Object { - "exists": Object { - "field": "summary", - }, - }, - ], - }, - }, - ], - }, - }, - ], }, }, "size": 25, diff --git a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_pings.ts b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_pings.ts index 4cedef1eacee2..2e94992b0d7f0 100644 --- a/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_pings.ts +++ b/x-pack/plugins/uptime/server/legacy_uptime/lib/requests/get_pings.ts @@ -6,6 +6,10 @@ */ import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { + EXCLUDE_RUN_ONCE_FILTER, + SUMMARY_FILTER, +} from '../../../../common/constants/client_defaults'; import { UMElasticsearchQueryFn } from '../adapters/framework'; import { GetPingsParams, @@ -16,43 +20,6 @@ import { const DEFAULT_PAGE_SIZE = 25; -/** - * This branch of filtering is used for monitors of type `browser`. This monitor - * type represents an unbounded set of steps, with each `check_group` representing - * a distinct journey. The document containing the `summary` field is indexed last, and - * contains the data necessary for querying a journey. - * - * Because of this, when querying for "pings", it is important that we treat `browser` summary - * checks as the "ping" we want. Without this filtering, we will receive >= N pings for a journey - * of N steps, because an individual step may also contain multiple documents. - */ -const REMOVE_NON_SUMMARY_BROWSER_CHECKS = { - must_not: [ - { - bool: { - filter: [ - { - term: { - 'monitor.type': 'browser', - }, - }, - { - bool: { - must_not: [ - { - exists: { - field: 'summary', - }, - }, - ], - }, - }, - ], - }, - }, - ], -}; - function isStringArray(value: unknown): value is string[] { if (!Array.isArray(value)) return false; // are all array items strings @@ -79,11 +46,12 @@ export const getPings: UMElasticsearchQueryFn = a query: { bool: { filter: [ + SUMMARY_FILTER, + EXCLUDE_RUN_ONCE_FILTER, { range: { '@timestamp': { gte: from, lte: to } } }, ...(monitorId ? [{ term: { 'monitor.id': monitorId } }] : []), ...(status ? [{ term: { 'monitor.status': status } }] : []), ] as QueryDslQueryContainer[], - ...REMOVE_NON_SUMMARY_BROWSER_CHECKS, }, }, sort: [{ '@timestamp': { order: (sort ?? 'desc') as 'asc' | 'desc' } }], diff --git a/x-pack/test/api_integration/apis/ml/trained_models/get_models.ts b/x-pack/test/api_integration/apis/ml/trained_models/get_models.ts index 0c1c90751b55d..78f4347cd091e 100644 --- a/x-pack/test/api_integration/apis/ml/trained_models/get_models.ts +++ b/x-pack/test/api_integration/apis/ml/trained_models/get_models.ts @@ -32,16 +32,16 @@ export default ({ getService }: FtrProviderContext) => { }); after(async () => { + await ml.api.cleanMlIndices(); + await esDeleteAllIndices('user-index_dfa*'); + // delete created ingest pipelines await Promise.all( ['dfa_regression_model_alias', ...testModelIds].map((modelId) => ml.api.deleteIngestPipeline(modelId) ) ); - await ml.api.cleanMlIndices(); await ml.testResources.cleanMLSavedObjects(); - - await esDeleteAllIndices('user-index_dfa*'); }); it('returns all trained models with associated pipelines including aliases', async () => { diff --git a/x-pack/test/api_integration/apis/uptime/constants.ts b/x-pack/test/api_integration/apis/uptime/constants.ts index 89fa3657848ab..2ec80d8d7ed76 100644 --- a/x-pack/test/api_integration/apis/uptime/constants.ts +++ b/x-pack/test/api_integration/apis/uptime/constants.ts @@ -7,5 +7,5 @@ import moment from 'moment'; -export const PINGS_DATE_RANGE_START = moment('2018-10-30T00:00:23.889Z').valueOf(); -export const PINGS_DATE_RANGE_END = moment('2018-10-31T00:00:00.889Z').valueOf(); +export const PINGS_DATE_RANGE_START = moment('2019-09-11T03:31:04.396Z').valueOf(); +export const PINGS_DATE_RANGE_END = moment('2020-10-31T00:00:00.889Z').valueOf(); diff --git a/x-pack/test/api_integration/apis/uptime/get_all_pings.ts b/x-pack/test/api_integration/apis/uptime/get_all_pings.ts index a7547d61992f2..4004fa1626eb1 100644 --- a/x-pack/test/api_integration/apis/uptime/get_all_pings.ts +++ b/x-pack/test/api_integration/apis/uptime/get_all_pings.ts @@ -16,7 +16,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('get_all_pings', () => { - const archive = 'x-pack/test/functional/es_archives/uptime/pings'; + const archive = 'x-pack/test/functional/es_archives/uptime/full_heartbeat'; before('load heartbeat data', async () => await esArchiver.load(archive)); after('unload heartbeat data', async () => await esArchiver.unload(archive)); @@ -31,9 +31,10 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(200); - expect(apiResponse.total).to.be(2); - expect(apiResponse.pings.length).to.be(2); - expect(apiResponse.pings[0].monitor.id).to.be('http@https://www.github.com/'); + expect(apiResponse.total).to.be(1931); + expect(apiResponse.pings.length).to.be(25); + expect(apiResponse.pings[0].monitor.id).to.be('0074-up'); + expect(apiResponse.pings[0].url.full).to.be('http://localhost:5678/pattern?r=200x1'); }); it('should sort pings according to timestamp', async () => { @@ -46,10 +47,10 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(200); - expect(apiResponse.total).to.be(2); - expect(apiResponse.pings.length).to.be(2); - expect(apiResponse.pings[0]['@timestamp']).to.be('2018-10-30T14:49:23.889Z'); - expect(apiResponse.pings[1]['@timestamp']).to.be('2018-10-30T18:51:56.792Z'); + expect(apiResponse.total).to.be(1931); + expect(apiResponse.pings.length).to.be(25); + expect(apiResponse.pings[0]['@timestamp']).to.be('2019-09-11T03:31:04.396Z'); + expect(apiResponse.pings[1]['@timestamp']).to.be('2019-09-11T03:31:04.396Z'); }); it('should return results of n length', async () => { @@ -63,9 +64,9 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(200); - expect(apiResponse.total).to.be(2); + expect(apiResponse.total).to.be(1931); expect(apiResponse.pings.length).to.be(1); - expect(apiResponse.pings[0].monitor.id).to.be('http@https://www.github.com/'); + expect(apiResponse.pings[0].monitor.id).to.be('0074-up'); }); it('should miss pings outside of date range', async () => { diff --git a/x-pack/test/api_integration/apis/uptime/rest/__snapshots__/monitor_states_real_data.snap b/x-pack/test/api_integration/apis/uptime/rest/__snapshots__/monitor_states_real_data.snap index 5bdccdcbb0c49..5429132fc346f 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/__snapshots__/monitor_states_real_data.snap +++ b/x-pack/test/api_integration/apis/uptime/rest/__snapshots__/monitor_states_real_data.snap @@ -1,280 +1,276 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`uptime uptime REST endpoints with real-world data monitor states endpoint will fetch monitor state data for the given down filters 1`] = ` -Object { - "nextPagePagination": "{\\"cursorDirection\\":\\"AFTER\\",\\"sortOrder\\":\\"ASC\\",\\"cursorKey\\":{\\"monitor_id\\":\\"0020-down\\"}}", - "prevPagePagination": null, - "summaries": Array [ - Object { - "monitor_id": "0010-down", - "state": Object { - "error": Object { - "message": "400 Bad Request", - "type": "validate", +Array [ + Object { + "monitor_id": "0010-down", + "state": Object { + "error": Object { + "message": "400 Bad Request", + "type": "validate", + }, + "monitor": Object { + "checkGroup": "d76f07d1-d445-11e9-88e3-3e80641b9c71", + "duration": Object { + "us": 37926, }, - "monitor": Object { - "checkGroup": "d76f07d1-d445-11e9-88e3-3e80641b9c71", - "duration": Object { - "us": 37926, - }, - "name": "", - "type": "http", + "name": "", + "type": "http", + }, + "observer": Object { + "geo": Object { + "name": Array [ + "mpls", + ], }, - "observer": Object { - "geo": Object { - "name": Array [ - "mpls", - ], + }, + "summary": Object { + "down": 1, + "status": "down", + "up": 0, + }, + "summaryPings": Array [ + Object { + "@timestamp": "2019-09-11T03:40:34.371Z", + "agent": Object { + "ephemeral_id": "412a92a8-2142-4b1a-a7a2-1afd32e12f85", + "hostname": "avc-x1x", + "id": "04e1d082-65bc-4929-8d65-d0768a2621c4", + "type": "heartbeat", + "version": "8.0.0", }, - }, - "summary": Object { - "down": 1, - "status": "down", - "up": 0, - }, - "summaryPings": Array [ - Object { - "@timestamp": "2019-09-11T03:40:34.371Z", - "agent": Object { - "ephemeral_id": "412a92a8-2142-4b1a-a7a2-1afd32e12f85", - "hostname": "avc-x1x", - "id": "04e1d082-65bc-4929-8d65-d0768a2621c4", - "type": "heartbeat", - "version": "8.0.0", - }, - "docId": "rZtoHm0B0I9WX_CznN_V", - "ecs": Object { - "version": "1.1.0", - }, - "error": Object { - "message": "400 Bad Request", - "type": "validate", - }, - "event": Object { - "dataset": "uptime", - }, - "host": Object { - "name": "avc-x1x", + "docId": "rZtoHm0B0I9WX_CznN_V", + "ecs": Object { + "version": "1.1.0", + }, + "error": Object { + "message": "400 Bad Request", + "type": "validate", + }, + "event": Object { + "dataset": "uptime", + }, + "host": Object { + "name": "avc-x1x", + }, + "http": Object { + "response": Object { + "body": Object { + "bytes": 3, + "content": "400", + "hash": "26d228663f13a88592a12d16cf9587caab0388b262d6d9f126ed62f9333aca94", + }, + "status_code": 400, }, - "http": Object { - "response": Object { - "body": Object { - "bytes": 3, - "content": "400", - "hash": "26d228663f13a88592a12d16cf9587caab0388b262d6d9f126ed62f9333aca94", - }, - "status_code": 400, + "rtt": Object { + "content": Object { + "us": 41, }, - "rtt": Object { - "content": Object { - "us": 41, - }, - "response_header": Object { - "us": 36777, - }, - "total": Object { - "us": 37821, - }, - "validate": Object { - "us": 36818, - }, - "write_request": Object { - "us": 53, - }, + "response_header": Object { + "us": 36777, }, - }, - "monitor": Object { - "check_group": "d76f07d1-d445-11e9-88e3-3e80641b9c71", - "duration": Object { - "us": 37926, + "total": Object { + "us": 37821, }, - "id": "0010-down", - "ip": "127.0.0.1", - "name": "", - "status": "down", - "type": "http", - }, - "observer": Object { - "geo": Object { - "location": "37.926868, -78.024902", - "name": "mpls", + "validate": Object { + "us": 36818, }, - "hostname": "avc-x1x", - }, - "resolve": Object { - "ip": "127.0.0.1", - "rtt": Object { - "us": 56, + "write_request": Object { + "us": 53, }, }, - "summary": Object { - "down": 1, - "up": 0, + }, + "monitor": Object { + "check_group": "d76f07d1-d445-11e9-88e3-3e80641b9c71", + "duration": Object { + "us": 37926, }, - "tcp": Object { - "rtt": Object { - "connect": Object { - "us": 890, - }, - }, + "id": "0010-down", + "ip": "127.0.0.1", + "name": "", + "status": "down", + "type": "http", + }, + "observer": Object { + "geo": Object { + "location": "37.926868, -78.024902", + "name": "mpls", }, - "timestamp": "2019-09-11T03:40:34.371Z", - "url": Object { - "domain": "localhost", - "full": "http://localhost:5678/pattern?r=400x1", - "path": "/pattern", - "port": 5678, - "query": "r=400x1", - "scheme": "http", + "hostname": "avc-x1x", + }, + "resolve": Object { + "ip": "127.0.0.1", + "rtt": Object { + "us": 56, }, }, - ], - "timestamp": "2019-09-11T03:40:34.371Z", - "url": Object { - "domain": "localhost", - "full": "http://localhost:5678/pattern?r=400x1", - "path": "/pattern", - "port": 5678, - "query": "r=400x1", - "scheme": "http", + "summary": Object { + "down": 1, + "up": 0, + }, + "tcp": Object { + "rtt": Object { + "connect": Object { + "us": 890, + }, + }, + }, + "timestamp": "2019-09-11T03:40:34.371Z", + "url": Object { + "domain": "localhost", + "full": "http://localhost:5678/pattern?r=400x1", + "path": "/pattern", + "port": 5678, + "query": "r=400x1", + "scheme": "http", + }, }, + ], + "timestamp": "2019-09-11T03:40:34.371Z", + "url": Object { + "domain": "localhost", + "full": "http://localhost:5678/pattern?r=400x1", + "path": "/pattern", + "port": 5678, + "query": "r=400x1", + "scheme": "http", }, }, - Object { - "monitor_id": "0020-down", - "state": Object { - "error": Object { - "message": "400 Bad Request", - "type": "validate", + }, + Object { + "monitor_id": "0020-down", + "state": Object { + "error": Object { + "message": "400 Bad Request", + "type": "validate", + }, + "monitor": Object { + "checkGroup": "d7712ecb-d445-11e9-88e3-3e80641b9c71", + "duration": Object { + "us": 14900, }, - "monitor": Object { - "checkGroup": "d7712ecb-d445-11e9-88e3-3e80641b9c71", - "duration": Object { - "us": 14900, - }, - "name": "", - "type": "http", + "name": "", + "type": "http", + }, + "observer": Object { + "geo": Object { + "name": Array [ + "mpls", + ], }, - "observer": Object { - "geo": Object { - "name": Array [ - "mpls", - ], + }, + "summary": Object { + "down": 1, + "status": "down", + "up": 0, + }, + "summaryPings": Array [ + Object { + "@timestamp": "2019-09-11T03:40:34.372Z", + "agent": Object { + "ephemeral_id": "412a92a8-2142-4b1a-a7a2-1afd32e12f85", + "hostname": "avc-x1x", + "id": "04e1d082-65bc-4929-8d65-d0768a2621c4", + "type": "heartbeat", + "version": "8.0.0", }, - }, - "summary": Object { - "down": 1, - "status": "down", - "up": 0, - }, - "summaryPings": Array [ - Object { - "@timestamp": "2019-09-11T03:40:34.372Z", - "agent": Object { - "ephemeral_id": "412a92a8-2142-4b1a-a7a2-1afd32e12f85", - "hostname": "avc-x1x", - "id": "04e1d082-65bc-4929-8d65-d0768a2621c4", - "type": "heartbeat", - "version": "8.0.0", - }, - "docId": "X5toHm0B0I9WX_CznN-6", - "ecs": Object { - "version": "1.1.0", - }, - "error": Object { - "message": "400 Bad Request", - "type": "validate", - }, - "event": Object { - "dataset": "uptime", - }, - "host": Object { - "name": "avc-x1x", + "docId": "X5toHm0B0I9WX_CznN-6", + "ecs": Object { + "version": "1.1.0", + }, + "error": Object { + "message": "400 Bad Request", + "type": "validate", + }, + "event": Object { + "dataset": "uptime", + }, + "host": Object { + "name": "avc-x1x", + }, + "http": Object { + "response": Object { + "body": Object { + "bytes": 3, + "content": "400", + "hash": "26d228663f13a88592a12d16cf9587caab0388b262d6d9f126ed62f9333aca94", + }, + "status_code": 400, }, - "http": Object { - "response": Object { - "body": Object { - "bytes": 3, - "content": "400", - "hash": "26d228663f13a88592a12d16cf9587caab0388b262d6d9f126ed62f9333aca94", - }, - "status_code": 400, + "rtt": Object { + "content": Object { + "us": 54, }, - "rtt": Object { - "content": Object { - "us": 54, - }, - "response_header": Object { - "us": 180, - }, - "total": Object { - "us": 555, - }, - "validate": Object { - "us": 234, - }, - "write_request": Object { - "us": 63, - }, + "response_header": Object { + "us": 180, }, - }, - "monitor": Object { - "check_group": "d7712ecb-d445-11e9-88e3-3e80641b9c71", - "duration": Object { - "us": 14900, + "total": Object { + "us": 555, }, - "id": "0020-down", - "ip": "127.0.0.1", - "name": "", - "status": "down", - "type": "http", - }, - "observer": Object { - "geo": Object { - "location": "37.926868, -78.024902", - "name": "mpls", + "validate": Object { + "us": 234, }, - "hostname": "avc-x1x", - }, - "resolve": Object { - "ip": "127.0.0.1", - "rtt": Object { - "us": 14294, + "write_request": Object { + "us": 63, }, }, - "summary": Object { - "down": 1, - "up": 0, + }, + "monitor": Object { + "check_group": "d7712ecb-d445-11e9-88e3-3e80641b9c71", + "duration": Object { + "us": 14900, }, - "tcp": Object { - "rtt": Object { - "connect": Object { - "us": 105, - }, - }, + "id": "0020-down", + "ip": "127.0.0.1", + "name": "", + "status": "down", + "type": "http", + }, + "observer": Object { + "geo": Object { + "location": "37.926868, -78.024902", + "name": "mpls", }, - "timestamp": "2019-09-11T03:40:34.372Z", - "url": Object { - "domain": "localhost", - "full": "http://localhost:5678/pattern?r=400x1", - "path": "/pattern", - "port": 5678, - "query": "r=400x1", - "scheme": "http", + "hostname": "avc-x1x", + }, + "resolve": Object { + "ip": "127.0.0.1", + "rtt": Object { + "us": 14294, }, }, - ], - "timestamp": "2019-09-11T03:40:34.372Z", - "url": Object { - "domain": "localhost", - "full": "http://localhost:5678/pattern?r=400x1", - "path": "/pattern", - "port": 5678, - "query": "r=400x1", - "scheme": "http", + "summary": Object { + "down": 1, + "up": 0, + }, + "tcp": Object { + "rtt": Object { + "connect": Object { + "us": 105, + }, + }, + }, + "timestamp": "2019-09-11T03:40:34.372Z", + "url": Object { + "domain": "localhost", + "full": "http://localhost:5678/pattern?r=400x1", + "path": "/pattern", + "port": 5678, + "query": "r=400x1", + "scheme": "http", + }, }, + ], + "timestamp": "2019-09-11T03:40:34.372Z", + "url": Object { + "domain": "localhost", + "full": "http://localhost:5678/pattern?r=400x1", + "path": "/pattern", + "port": 5678, + "query": "r=400x1", + "scheme": "http", }, }, - ], -} + }, +] `; diff --git a/x-pack/test/api_integration/apis/uptime/rest/monitor_states_real_data.ts b/x-pack/test/api_integration/apis/uptime/rest/monitor_states_real_data.ts index 1f0e04a74db71..94ef6fdf034bb 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/monitor_states_real_data.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/monitor_states_real_data.ts @@ -85,7 +85,7 @@ export default function ({ getService }: FtrProviderContext) { `${API_URLS.MONITOR_LIST}?dateRangeStart=${from}&dateRangeEnd=${to}&statusFilter=${statusFilter}&pageSize=${size}` ); - expectSnapshot(body).toMatch(); + expectSnapshot(body.summaries).toMatch(); }); it('can navigate forward and backward using pagination', async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_score_calculation.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_score_calculation.ts index 1f504cce21be4..31a00c92593df 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_score_calculation.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_score_calculation.ts @@ -38,6 +38,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: result } = await supertest .post(RISK_SCORE_CALCULATION_URL) .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') .send(body) .expect(200); return result; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_score_preview.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_score_preview.ts index 4a61643293dec..31f8a86efc56e 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_score_preview.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_score_preview.ts @@ -36,6 +36,7 @@ export default ({ getService }: FtrProviderContext): void => { const defaultBody = { data_view_id: '.alerts-security.alerts-default' }; const { body: result } = await supertest .post(RISK_SCORE_PREVIEW_URL) + .set('elastic-api-version', '1') .set('kbn-xsrf', 'true') .send({ ...defaultBody, ...body }) .expect(200); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/utils.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/utils.ts index 0d0cbd3d36d06..4671c0006113c 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/utils.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/utils.ts @@ -427,6 +427,7 @@ export const riskEngineRouteHelpersFactory = ( await supertest .post(routeWithNamespace(RISK_ENGINE_INIT_URL, namespace)) .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') .send() .expect(200), @@ -434,6 +435,7 @@ export const riskEngineRouteHelpersFactory = ( await supertest .get(routeWithNamespace(RISK_ENGINE_STATUS_URL, namespace)) .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') .send() .expect(200), @@ -441,6 +443,7 @@ export const riskEngineRouteHelpersFactory = ( await supertest .post(routeWithNamespace(RISK_ENGINE_ENABLE_URL, namespace)) .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') .send() .expect(200), @@ -448,6 +451,7 @@ export const riskEngineRouteHelpersFactory = ( await supertest .post(routeWithNamespace(RISK_ENGINE_DISABLE_URL, namespace)) .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') .send() .expect(200), }); @@ -460,12 +464,14 @@ export const installLegacyRiskScore = async ({ await supertest .post('/internal/risk_score') .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') .send({ riskScoreEntity: 'host' }) .expect(200); await supertest .post('/internal/risk_score') .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') .send({ riskScoreEntity: 'user' }) .expect(200); diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_endpoint.ts b/x-pack/test/fleet_api_integration/apis/epm/install_endpoint.ts index 2ba688dfb8bf5..892f89f7c2bb6 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_endpoint.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_endpoint.ts @@ -15,7 +15,8 @@ export default function (providerContext: FtrProviderContext) { * There are a few features that are only currently supported for the Endpoint * package due to security concerns. */ - describe('Install endpoint package', () => { + // Failing: See https://github.com/elastic/kibana/issues/156941 + describe.skip('Install endpoint package', () => { const { getService } = providerContext; skipIfNoDockerRegistry(providerContext); setupFleetAndAgents(providerContext); diff --git a/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts b/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts index 490ba84c8496c..6ecaa84c96974 100644 --- a/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts +++ b/x-pack/test/functional/apps/dashboard/group3/reporting/screenshots.ts @@ -226,7 +226,7 @@ export default function ({ updateBaselines ); - expect(percentDiff).to.be.lessThan(0.03); + expect(percentDiff).to.be.lessThan(0.035); }); }); }); diff --git a/x-pack/test/functional/apps/infra/home_page.ts b/x-pack/test/functional/apps/infra/home_page.ts index a6dc2754de359..80ae3d2a59b8b 100644 --- a/x-pack/test/functional/apps/infra/home_page.ts +++ b/x-pack/test/functional/apps/infra/home_page.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { parse } from 'url'; import { KUBERNETES_TOUR_STORAGE_KEY } from '@kbn/infra-plugin/public/pages/metrics/inventory_view/components/kubernetes_tour'; import { FtrProviderContext } from '../../ftr_provider_context'; import { DATES, INVENTORY_PATH } from './constants'; @@ -18,7 +19,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const browser = getService('browser'); const retry = getService('retry'); - const pageObjects = getPageObjects(['common', 'header', 'infraHome', 'infraSavedViews']); + const pageObjects = getPageObjects([ + 'common', + 'header', + 'infraHome', + 'timePicker', + 'assetDetails', + 'infraSavedViews', + ]); const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); @@ -119,6 +127,92 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.infraHome.getWaffleMap(); // await pageObjects.infraHome.getWaffleMapTooltips(); see https://github.com/elastic/kibana/issues/137903 }); + + describe('Asset Details flyout', () => { + before(async () => { + await pageObjects.infraHome.goToTime(DATE_WITH_DATA); + await pageObjects.infraHome.getWaffleMap(); + await pageObjects.infraHome.inputAddHostNameFilter('demo-stack-nginx-01'); + await pageObjects.infraHome.clickOnNode(); + }); + + describe('Overview Tab', () => { + before(async () => { + await pageObjects.assetDetails.clickOverviewTab(); + }); + + [ + { metric: 'cpuUsage', value: '0.8%' }, + { metric: 'normalizedLoad1m', value: '1.4%' }, + { metric: 'memoryUsage', value: '18.0%' }, + { metric: 'diskSpaceUsage', value: '17.5%' }, + ].forEach(({ metric, value }) => { + it(`${metric} tile should show ${value}`, async () => { + await retry.tryForTime(3 * 1000, async () => { + const tileValue = await pageObjects.assetDetails.getAssetDetailsKPITileValue( + metric + ); + expect(tileValue).to.eql(value); + }); + }); + }); + + it('should render 9 charts in the Metrics section', async () => { + const hosts = await pageObjects.assetDetails.getAssetDetailsMetricsCharts(); + expect(hosts.length).to.equal(9); + }); + + it('should show alerts', async () => { + await pageObjects.header.waitUntilLoadingHasFinished(); + await pageObjects.assetDetails.overviewAlertsTitleExists(); + }); + }); + + describe('Metadata Tab', () => { + before(async () => { + await pageObjects.assetDetails.clickMetadataTab(); + }); + + it('should show metadata table', async () => { + await pageObjects.assetDetails.metadataTableExists(); + }); + }); + + describe('Logs Tab', () => { + before(async () => { + await pageObjects.assetDetails.clickLogsTab(); + }); + + after(async () => { + await retry.try(async () => { + await pageObjects.infraHome.closeFlyout(); + }); + }); + + it('should render logs tab', async () => { + await pageObjects.assetDetails.logsExists(); + }); + }); + + describe('APM Link Tab', () => { + before(async () => { + await pageObjects.infraHome.clickOnNode(); + await pageObjects.assetDetails.clickApmTabLink(); + }); + + it('should navigate to APM traces', async () => { + const url = parse(await browser.getCurrentUrl()); + const query = decodeURIComponent(url.query ?? ''); + const kuery = 'kuery=host.hostname:"demo-stack-nginx-01"'; + + expect(url.pathname).to.eql('/app/apm/traces'); + expect(query).to.contain(kuery); + + await returnTo(INVENTORY_PATH); + }); + }); + }); + it('shows query suggestions', async () => { await pageObjects.infraHome.goToTime(DATE_WITH_DATA); await pageObjects.infraHome.clickQueryBar(); diff --git a/x-pack/test/functional/apps/lens/group6/annotations.ts b/x-pack/test/functional/apps/lens/group6/annotations.ts index aa3b409d21fb0..cac637ca64ff6 100644 --- a/x-pack/test/functional/apps/lens/group6/annotations.ts +++ b/x-pack/test/functional/apps/lens/group6/annotations.ts @@ -26,7 +26,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const from = 'Sep 19, 2015 @ 06:31:44.000'; const to = 'Sep 23, 2015 @ 18:31:44.000'; - describe('lens annotations tests', () => { + // Failing: See https://github.com/elastic/kibana/issues/167552 + describe.skip('lens annotations tests', () => { before(async () => { await PageObjects.common.setTime({ from, to }); }); diff --git a/x-pack/test/functional/apps/lens/group6/lens_tagging.ts b/x-pack/test/functional/apps/lens/group6/lens_tagging.ts index c3b279f591cdb..b8d9c332f64f5 100644 --- a/x-pack/test/functional/apps/lens/group6/lens_tagging.ts +++ b/x-pack/test/functional/apps/lens/group6/lens_tagging.ts @@ -29,7 +29,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const lensTag = 'extreme-lens-tag'; const lensTitle = 'lens tag test'; - describe('lens tagging', () => { + // Failing: See https://github.com/elastic/kibana/issues/167561 + describe.skip('lens tagging', () => { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); diff --git a/x-pack/test/functional/page_objects/asset_details.ts b/x-pack/test/functional/page_objects/asset_details.ts index f7f666208275c..637199b8e623b 100644 --- a/x-pack/test/functional/page_objects/asset_details.ts +++ b/x-pack/test/functional/page_objects/asset_details.ts @@ -180,5 +180,10 @@ export function AssetDetailsProvider({ getService }: FtrProviderContext) { async clickOsqueryTab() { return testSubjects.click('infraAssetDetailsOsqueryTab'); }, + + // APM Tab link + async clickApmTabLink() { + return testSubjects.click('infraAssetDetailsApmServicesLinkTab'); + }, }; } diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index 2f3d0209d857f..31fb805b69d22 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -105,7 +105,7 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide async clickOnNodeDetailsFlyoutOpenAsPage() { await retry.try(async () => { - await testSubjects.click('infraNodeContextPopoverOpenAsPageButton'); + await testSubjects.click('infraAssetDetailsOpenAsPageButton'); }); }, @@ -434,6 +434,14 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide await queryBar.type('h'); }, + async inputAddHostNameFilter(hostName: string) { + await this.enterSearchTerm(`host.name:"${hostName}"`); + }, + + async clickOnNode() { + return testSubjects.click('nodeContainer'); + }, + async ensureSuggestionsPanelVisible() { await testSubjects.find('infraSuggestionsPanel'); }, diff --git a/x-pack/test/functional/services/ml/api.ts b/x-pack/test/functional/services/ml/api.ts index e2db084b46276..cf71764ee5397 100644 --- a/x-pack/test/functional/services/ml/api.ts +++ b/x-pack/test/functional/services/ml/api.ts @@ -1504,10 +1504,10 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { async deleteIngestPipeline(modelId: string, usePrefix: boolean = true) { log.debug(`Deleting ingest pipeline for trained model with id "${modelId}"`); - // const { body, status } = - await esSupertest.delete(`/_ingest/pipeline/${usePrefix ? 'pipeline_' : ''}${modelId}`); - // @todo - // this.assertResponseStatusCode(200, status, body); + const { body, status } = await esSupertest.delete( + `/_ingest/pipeline/${usePrefix ? 'pipeline_' : ''}${modelId}` + ); + this.assertResponseStatusCode(200, status, body); log.debug('> Ingest pipeline deleted'); }, diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/test_utils.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/test_utils.ts index ca821c491c9c4..39c9a8bb22b0b 100644 --- a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/test_utils.ts +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/test_utils.ts @@ -40,11 +40,23 @@ export const createTags = async ({ getService }: FtrProviderContext) => { export const deleteTags = async ({ getService }: FtrProviderContext) => { const kibanaServer = getService('kibanaServer'); - await kibanaServer.importExport.unload( - 'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/rbac_tags/default_space.json' - ); - await kibanaServer.importExport.unload( - 'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/rbac_tags/space_1.json', - { space: 'space_1' } - ); + while (true) { + const defaultTags = await kibanaServer.savedObjects.find({ type: 'tag', space: 'default' }); + const spaceTags = await kibanaServer.savedObjects.find({ type: 'tag', space: 'space_1' }); + if (defaultTags.saved_objects.length === 0 && spaceTags.saved_objects.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + break; + } + if (defaultTags.saved_objects.length !== 0) { + await kibanaServer.savedObjects.bulkDelete({ + objects: defaultTags.saved_objects.map(({ type, id }) => ({ type, id })), + }); + } + if (spaceTags.saved_objects.length !== 0) { + await kibanaServer.savedObjects.bulkDelete({ + objects: spaceTags.saved_objects.map(({ type, id }) => ({ type, id })), + space: 'space_1', + }); + } + } }; diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/create.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/create.ts index 4c867ca50b11e..60cbc51364813 100644 --- a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/create.ts +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/create.ts @@ -59,6 +59,8 @@ export default function ({ getService }: FtrProviderContext) { }, }); }); + + await supertest.delete(`/api/saved_objects_tagging/tags/${newTagId}`); }); it('should return an error with details when validation failed', async () => { @@ -86,5 +88,24 @@ export default function ({ getService }: FtrProviderContext) { }); }); }); + + it('cannot create a new tag with existing name', async () => { + const existingName = 'tag-1'; + await supertest + .post(`/api/saved_objects_tagging/tags/create`) + .send({ + name: existingName, + description: 'some desc', + color: '#000000', + }) + .expect(409) + .then(({ body }) => { + expect(body).to.eql({ + statusCode: 409, + error: 'Conflict', + message: `A tag with the name "${existingName}" already exists.`, + }); + }); + }); }); } diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/index.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/index.ts index f291d2537ed02..e8b16d5878f2d 100644 --- a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/index.ts +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/index.ts @@ -14,6 +14,5 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./bulk_assign')); - loadTestFile(require.resolve('./usage_collection')); }); } diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/update.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/update.ts index 2efe8184ed7c9..b75513bb001da 100644 --- a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/update.ts +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/update.ts @@ -61,6 +61,39 @@ export default function ({ getService }: FtrProviderContext) { }); }); + it('should not allow updating tag name to an existing name', async () => { + const existingName = 'tag-3'; + await supertest + .post(`/api/saved_objects_tagging/tags/tag-2`) + .send({ + name: existingName, + description: 'updated desc', + color: '#123456', + }) + .expect(409) + .then(({ body }) => { + expect(body).to.eql({ + statusCode: 409, + error: 'Conflict', + message: `A tag with the name "${existingName}" already exists.`, + }); + }); + + await supertest + .get(`/api/saved_objects_tagging/tags/tag-3`) + .expect(200) + .then(({ body }) => { + expect(body).to.eql({ + tag: { + id: 'tag-3', + name: 'tag-3', + description: 'Last but not least', + color: '#000000', + }, + }); + }); + }); + it('should return a 404 when trying to update a non existing tag', async () => { await supertest .post(`/api/saved_objects_tagging/tags/unknown-tag-id`) diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_usage_collection/config.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_usage_collection/config.ts new file mode 100644 index 0000000000000..22edd56fffdbc --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_usage_collection/config.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; +import { services } from './services'; + +// eslint-disable-next-line import/no-default-export +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const apiIntegrationConfig = await readConfigFile( + require.resolve('../../../api_integration/config.ts') + ); + + return { + testFiles: [require.resolve('./tests')], + servers: apiIntegrationConfig.get('servers'), + services, + junit: { + reportName: 'X-Pack Saved Object Tagging Usage Collection', + }, + esTestCluster: { + ...apiIntegrationConfig.get('esTestCluster'), + license: 'trial', + }, + kbnTestServer: { + ...apiIntegrationConfig.get('kbnTestServer'), + serverArgs: [ + ...apiIntegrationConfig.get('kbnTestServer.serverArgs'), + '--server.xsrf.disableProtection=true', + ], + }, + }; +} diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_usage_collection/services.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_usage_collection/services.ts new file mode 100644 index 0000000000000..194d6ec533066 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_usage_collection/services.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GenericFtrProviderContext } from '@kbn/test'; +import { services as apiIntegrationServices } from '../../../api_integration/services'; + +export const services = { + ...apiIntegrationServices, +}; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/usage_collection.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_usage_collection/tests.ts similarity index 94% rename from x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/usage_collection.ts rename to x-pack/test/saved_object_tagging/api_integration/tagging_usage_collection/tests.ts index d5d042ad9f956..b8dab8d648333 100644 --- a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/usage_collection.ts +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_usage_collection/tests.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../services'; +import { FtrProviderContext } from './services'; // eslint-disable-next-line import/no-default-export export default function ({ getService }: FtrProviderContext) { @@ -15,6 +15,7 @@ export default function ({ getService }: FtrProviderContext) { describe('saved_object_tagging usage collector data', () => { beforeEach(async () => { + await kibanaServer.savedObjects.cleanStandardList(); await kibanaServer.importExport.load( 'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/usage_collection/data.json' ); diff --git a/x-pack/test/saved_object_tagging/functional/tests/discover_integration.ts b/x-pack/test/saved_object_tagging/functional/tests/discover_integration.ts index 4529bf260fef1..7a82d0aec5d34 100644 --- a/x-pack/test/saved_object_tagging/functional/tests/discover_integration.ts +++ b/x-pack/test/saved_object_tagging/functional/tests/discover_integration.ts @@ -52,7 +52,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }; - describe('discover integration', () => { + // Failing: See https://github.com/elastic/kibana/issues/150249 + describe.skip('discover integration', () => { before(async () => { await kibanaServer.importExport.load( 'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/discover/data.json' diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/discover/discover_state.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/discover/discover_state.cy.ts index 1a11e677e74d0..ad73a9b33524f 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/discover/discover_state.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/discover/discover_state.cy.ts @@ -89,8 +89,8 @@ describe( navigateFromHeaderTo(ALERTS); openActiveTimeline(); gotToDiscoverTab(); - cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER('host.name')).should('be.visible'); - cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER('user.name')).should('be.visible'); + cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER('host.name')).should('exist'); + cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER('user.name')).should('exist'); }); } ); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/discover/discover_timeline_state_integration.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/discover/discover_timeline_state_integration.cy.ts new file mode 100644 index 0000000000000..9f8612551c51c --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/discover/discover_timeline_state_integration.cy.ts @@ -0,0 +1,293 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { visitWithTimeRange } from '../../../../tasks/navigation'; +import { TIMELINE_TITLE } from '../../../../screens/timeline'; +import { BASIC_TABLE_LOADING } from '../../../../screens/common'; +import { goToSavedObjectSettings } from '../../../../tasks/stack_management'; +import { + navigateFromKibanaCollapsibleTo, + openKibanaNavigation, +} from '../../../../tasks/kibana_navigation'; +import { fillAddFilterForm } from '../../../../tasks/search_bar'; +import { + addDiscoverKqlQuery, + addFieldToTable, + openAddDiscoverFilterPopover, + switchDataViewTo, + switchDataViewToESQL, +} from '../../../../tasks/discover'; +import { + GET_LOCAL_DATE_PICKER_START_DATE_POPOVER_BUTTON, + GET_LOCAL_SHOW_DATES_BUTTON, +} from '../../../../screens/date_picker'; +import { ALERTS_URL } from '../../../../urls/navigation'; +import { + DISCOVER_CONTAINER, + DISCOVER_DATA_VIEW_SWITCHER, + DISCOVER_FILTER_BADGES, + DISCOVER_QUERY_INPUT, + GET_DISCOVER_DATA_GRID_CELL_HEADER, +} from '../../../../screens/discover'; +import { updateDateRangeInLocalDatePickers } from '../../../../tasks/date_picker'; +import { login } from '../../../../tasks/login'; +import { + addDescriptionToTimeline, + addNameToTimeline, + createNewTimeline, + gotToDiscoverTab, + openTimelineById, + openTimelineFromSettings, + waitForTimelineChanges, +} from '../../../../tasks/timeline'; +import { LOADING_INDICATOR } from '../../../../screens/security_header'; +import { STACK_MANAGEMENT_PAGE } from '../../../../screens/kibana_navigation'; +import { + GET_SAVED_OBJECTS_TAGS_OPTION, + SAVED_OBJECTS_ROW_TITLES, + SAVED_OBJECTS_TAGS_FILTER, +} from '../../../../screens/common/stack_management'; + +const INITIAL_START_DATE = 'Jan 18, 2021 @ 20:33:29.186'; +const INITIAL_END_DATE = 'Jan 19, 2024 @ 20:33:29.186'; +const SAVED_SEARCH_UPDATE_REQ = 'SAVED_SEARCH_UPDATE_REQ'; +const SAVED_SEARCH_UPDATE_WITH_DESCRIPTION = 'SAVED_SEARCH_UPDATE_WITH_DESCRIPTION'; +const SAVED_SEARCH_CREATE_REQ = 'SAVED_SEARCH_CREATE_REQ'; +const SAVED_SEARCH_GET_REQ = 'SAVED_SEARCH_GET_REQ'; +const TIMELINE_REQ_WITH_SAVED_SEARCH = 'TIMELINE_REQ_WITH_SAVED_SEARCH'; +const TIMELINE_PATCH_REQ = 'TIMELINE_PATCH_REQ'; + +const TIMELINE_RESPONSE_SAVED_OBJECT_ID_PATH = + 'response.body.data.persistTimeline.timeline.savedObjectId'; + +describe( + 'Discover Timeline State Integration', + { + env: { ftrConfig: { enableExperimental: ['discoverInTimeline'] } }, + tags: ['@ess', '@brokenInServerless'], + // ESQL and test involving STACK_MANAGEMENT_PAGE are broken in serverless + }, + + () => { + beforeEach(() => { + cy.intercept('PATCH', '/api/timeline', (req) => { + if (req.body.hasOwnProperty('timeline') && req.body.timeline.savedSearchId === null) { + req.alias = TIMELINE_PATCH_REQ; + } + }); + cy.intercept('PATCH', '/api/timeline', (req) => { + if (req.body.hasOwnProperty('timeline') && req.body.timeline.savedSearchId !== null) { + req.alias = TIMELINE_REQ_WITH_SAVED_SEARCH; + } + }); + cy.intercept('POST', '/api/content_management/rpc/get', (req) => { + if (req.body.hasOwnProperty('contentTypeId') && req.body.contentTypeId === 'search') { + req.alias = SAVED_SEARCH_GET_REQ; + } + }); + cy.intercept('POST', '/api/content_management/rpc/create', (req) => { + if (req.body.hasOwnProperty('contentTypeId') && req.body.contentTypeId === 'search') { + req.alias = SAVED_SEARCH_CREATE_REQ; + } + }); + + cy.intercept('POST', '/api/content_management/rpc/update', (req) => { + if (req.body.hasOwnProperty('contentTypeId') && req.body.contentTypeId === 'search') { + req.alias = SAVED_SEARCH_UPDATE_REQ; + } + }); + cy.intercept('POST', '/api/content_management/rpc/update', (req) => { + if ( + req.body.hasOwnProperty('data') && + req.body.data.hasOwnProperty('description') && + req.body.data.description.length > 0 + ) { + req.alias = SAVED_SEARCH_UPDATE_WITH_DESCRIPTION; + } + }); + login(); + visitWithTimeRange(ALERTS_URL); + createNewTimeline(); + gotToDiscoverTab(); + updateDateRangeInLocalDatePickers(DISCOVER_CONTAINER, INITIAL_START_DATE, INITIAL_END_DATE); + }); + context('save/restore', () => { + it('should be able create an empty timeline with default discover state', () => { + addNameToTimeline('Timerange timeline'); + createNewTimeline(); + gotToDiscoverTab(); + cy.get(GET_LOCAL_SHOW_DATES_BUTTON(DISCOVER_CONTAINER)).should( + 'contain.text', + `Last 15 minutes` + ); + }); + it('should save/restore discover dataview/timerange/filter/query/columns when saving/resoring timeline', () => { + const dataviewName = '.kibana-event-log'; + const timelineSuffix = Date.now(); + const timelineName = `DataView timeline-${timelineSuffix}`; + const kqlQuery = '_id:*'; + const column1 = 'event.category'; + const column2 = 'ecs.version'; + switchDataViewTo(dataviewName); + addDiscoverKqlQuery(kqlQuery); + openAddDiscoverFilterPopover(); + fillAddFilterForm({ + key: 'ecs.version', + value: '1.8.0', + }); + addFieldToTable(column1); + addFieldToTable(column2); + + // create a custom timeline + addNameToTimeline(timelineName); + cy.wait(`@${TIMELINE_PATCH_REQ}`) + .its(TIMELINE_RESPONSE_SAVED_OBJECT_ID_PATH) + .then((timelineId) => { + cy.wait(`@${TIMELINE_REQ_WITH_SAVED_SEARCH}`); + // create an empty timeline + createNewTimeline(); + // switch to old timeline + openTimelineFromSettings(); + openTimelineById(timelineId); + cy.get(LOADING_INDICATOR).should('not.exist'); + gotToDiscoverTab(); + cy.get(DISCOVER_DATA_VIEW_SWITCHER.BTN).should('contain.text', dataviewName); + cy.get(DISCOVER_QUERY_INPUT).should('have.text', kqlQuery); + cy.get(DISCOVER_FILTER_BADGES).should('have.length', 1); + cy.get(DISCOVER_FILTER_BADGES).should('contain.text', 'ecs.version: 1.8.0'); + cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER(column1)).should('exist'); + cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER(column2)).should('exist'); + cy.get(GET_LOCAL_DATE_PICKER_START_DATE_POPOVER_BUTTON(DISCOVER_CONTAINER)).should( + 'have.text', + INITIAL_START_DATE + ); + }); + }); + it('should save/restore discover dataview/timerange/filter/query/columns when timeline is opened via url', () => { + const dataviewName = '.kibana-event-log'; + const timelineSuffix = Date.now(); + const timelineName = `DataView timeline-${timelineSuffix}`; + const kqlQuery = '_id:*'; + const column1 = 'event.category'; + const column2 = 'ecs.version'; + switchDataViewTo(dataviewName); + addDiscoverKqlQuery(kqlQuery); + openAddDiscoverFilterPopover(); + fillAddFilterForm({ + key: 'ecs.version', + value: '1.8.0', + }); + addFieldToTable(column1); + addFieldToTable(column2); + + // create a custom timeline + addNameToTimeline(timelineName); + cy.wait(`@${TIMELINE_PATCH_REQ}`) + .its(TIMELINE_RESPONSE_SAVED_OBJECT_ID_PATH) + .then((timelineId) => { + cy.wait(`@${TIMELINE_REQ_WITH_SAVED_SEARCH}`); + // reload the page with the exact url + cy.reload(); + cy.get(DISCOVER_DATA_VIEW_SWITCHER.BTN).should('contain.text', dataviewName); + cy.get(DISCOVER_QUERY_INPUT).should('have.text', kqlQuery); + cy.get(DISCOVER_FILTER_BADGES).should('have.length', 1); + cy.get(DISCOVER_FILTER_BADGES).should('contain.text', 'ecs.version: 1.8.0'); + cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER(column1)).should('exist'); + cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER(column2)).should('exist'); + cy.get(GET_LOCAL_DATE_PICKER_START_DATE_POPOVER_BUTTON(DISCOVER_CONTAINER)).should( + 'have.text', + INITIAL_START_DATE + ); + }); + }); + it('should save/restore discover ES|QL when saving timeline', () => { + const timelineSuffix = Date.now(); + const timelineName = `ES|QL timeline-${timelineSuffix}`; + switchDataViewToESQL(); + addNameToTimeline(timelineName); + cy.wait(`@${TIMELINE_PATCH_REQ}`) + .its(TIMELINE_RESPONSE_SAVED_OBJECT_ID_PATH) + .then((timelineId) => { + cy.wait(`@${TIMELINE_REQ_WITH_SAVED_SEARCH}`); + // create an empty timeline + createNewTimeline(); + // switch to old timeline + openTimelineFromSettings(); + openTimelineById(timelineId); + cy.get(LOADING_INDICATOR).should('not.exist'); + gotToDiscoverTab(); + cy.get(DISCOVER_DATA_VIEW_SWITCHER.BTN).should('contain.text', 'ES|QL'); + }); + }); + }); + /* + * skipping because it is @brokenInServerless and this cypress tag was somehow not working + * so skipping this test both in ess and serverless. + * + * Raised issue: https://github.com/elastic/kibana/issues/165913 + * + * */ + context.skip('saved search tags', () => { + it('should save discover saved search with `Security Solution` tag', () => { + const timelineSuffix = Date.now(); + const timelineName = `SavedObject timeline-${timelineSuffix}`; + const kqlQuery = '_id: *'; + addDiscoverKqlQuery(kqlQuery); + addNameToTimeline(timelineName); + cy.wait(`@${TIMELINE_REQ_WITH_SAVED_SEARCH}`); + openKibanaNavigation(); + navigateFromKibanaCollapsibleTo(STACK_MANAGEMENT_PAGE); + cy.get(LOADING_INDICATOR).should('not.exist'); + goToSavedObjectSettings(); + cy.get(LOADING_INDICATOR).should('not.exist'); + cy.get(SAVED_OBJECTS_TAGS_FILTER).trigger('click'); + cy.get(GET_SAVED_OBJECTS_TAGS_OPTION('Security_Solution')).trigger('click'); + cy.get(BASIC_TABLE_LOADING).should('not.exist'); + cy.get(SAVED_OBJECTS_ROW_TITLES).should( + 'contain.text', + `Saved Search for timeline - ${timelineName}` + ); + }); + }); + context('saved search', () => { + it('should rename the saved search on timeline rename', () => { + const timelineSuffix = Date.now(); + const timelineName = `Rename timeline-${timelineSuffix}`; + const kqlQuery = '_id: *'; + addDiscoverKqlQuery(kqlQuery); + + addNameToTimeline(timelineName); + cy.wait(`@${TIMELINE_PATCH_REQ}`) + .its(TIMELINE_RESPONSE_SAVED_OBJECT_ID_PATH) + .then((timelineId) => { + cy.wait(`@${SAVED_SEARCH_UPDATE_REQ}`); + cy.wait(`@${TIMELINE_REQ_WITH_SAVED_SEARCH}`); + // create an empty timeline + createNewTimeline(); + // switch to old timeline + openTimelineFromSettings(); + openTimelineById(timelineId); + cy.get(TIMELINE_TITLE).should('have.text', timelineName); + const timelineDesc = 'Timeline Description with Saved Seach'; + waitForTimelineChanges(); + addDescriptionToTimeline(timelineDesc); + cy.wait(`@${SAVED_SEARCH_UPDATE_WITH_DESCRIPTION}`, { + timeout: 30000, + }).then((interception) => { + expect(interception.request.body.data.description).eq(timelineDesc); + }); + }); + }); + }); + + // Issue for enabling below tests: https://github.com/elastic/kibana/issues/165913 + context.skip('Advanced Settings', () => { + it('rows per page in saved search should be according to the user selected number of pages', () => {}); + it('rows per page in new search should be according to the value selected in advanced settings', () => {}); + }); + } +); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/common/stack_management.ts b/x-pack/test/security_solution_cypress/cypress/screens/common/stack_management.ts new file mode 100644 index 0000000000000..acb9ac4f324a8 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/screens/common/stack_management.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getDataTestSubjectSelector } from '../../helpers/common'; + +export const STACK_MANAGEMENT_HOME = getDataTestSubjectSelector('managementHome'); + +export const SAVED_OBJECTS_SETTINGS = `${getDataTestSubjectSelector('objects')}`; + +export const SAVED_OBJECTS_TAGS_FILTER = '[data-text="Tags"][title="Tags"]'; + +export const GET_SAVED_OBJECTS_TAGS_OPTION = (optionId: string) => + getDataTestSubjectSelector(`tag-searchbar-option-${optionId}`); + +export const SAVED_OBJECTS_SEARCH_BAR = getDataTestSubjectSelector('savedObjectSearchBar'); + +export const SAVED_OBJECTS_ROW_TITLES = getDataTestSubjectSelector('savedObjectsTableRowTitle'); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/discover.ts b/x-pack/test/security_solution_cypress/cypress/screens/discover.ts index 72f0801fde778..a728c1f4c82bd 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/discover.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/discover.ts @@ -15,6 +15,7 @@ export const DISCOVER_DATA_VIEW_SWITCHER = { INPUT: getDataTestSubjectSelector('indexPattern-switcher--input'), GET_DATA_VIEW: (title: string) => `.euiSelectableListItem[role=option][title^="${title}"]`, CREATE_NEW: getDataTestSubjectSelector('dataview-create-new'), + TEXT_BASE_LANG_SWICTHER: getDataTestSubjectSelector('select-text-based-language-panel'), }; export const DISCOVER_DATA_VIEW_EDITOR_FLYOUT = { diff --git a/x-pack/test/security_solution_cypress/cypress/screens/kibana_navigation.ts b/x-pack/test/security_solution_cypress/cypress/screens/kibana_navigation.ts index d9b3f23f13873..739e33a14f254 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/kibana_navigation.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/kibana_navigation.ts @@ -38,3 +38,6 @@ export const SPACES_BUTTON = '[data-test-subj="spacesNavSelector"]'; export const APP_LEAVE_CONFIRM_MODAL = '[data-test-subj="appLeaveConfirmModal"]'; export const getGoToSpaceMenuItem = (space: string) => `[data-test-subj="space-avatar-${space}"]`; + +export const STACK_MANAGEMENT_PAGE = + '[data-test-subj="collapsibleNavAppLink"] [title="Stack Management"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts index 7df6b0408c19e..78b25d890c9c7 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts @@ -6,7 +6,7 @@ */ import type { TimelineFilter } from '../objects/timeline'; -import { getDataTestSubjectSelector } from '../helpers/common'; +import { getDataTestSubjectSelector, getDataTestSubjectSelectorStartWith } from '../helpers/common'; export const ADD_NOTE_BUTTON = '[data-test-subj="add-note"]'; @@ -209,7 +209,7 @@ export const TIMELINE_FILTER = (filter: TimelineFilter) => export const TIMELINE_FILTER_FIELD = '[data-test-subj="filterFieldSuggestionList"]'; -export const TIMELINE_TITLE_BY_ID = (id: string) => `[data-test-subj="title-${id}"]`; +export const TIMELINE_TITLE_BY_ID = (id: string) => `[data-test-subj="timeline-title-${id}"]`; export const TIMELINE_FILTER_OPERATOR = '[data-test-subj="filterOperatorList"]'; @@ -346,3 +346,11 @@ export const DISCOVER_TAB = getDataTestSubjectSelector('timelineTabs-discover'); export const TIMELINE_DATE_PICKER_CONTAINER = getDataTestSubjectSelector( 'timeline-date-picker-container' ); + +export const OPEN_TIMELINE_MODAL_SEARCH_BAR = `${OPEN_TIMELINE_MODAL} ${getDataTestSubjectSelector( + 'search-bar' +)}`; + +export const OPEN_TIMELINE_MODAL_TIMELINE_NAMES = `${OPEN_TIMELINE_MODAL} ${getDataTestSubjectSelectorStartWith( + 'timeline-title-' +)}`; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/timelines.ts b/x-pack/test/security_solution_cypress/cypress/screens/timelines.ts index d38ccfb0ea49e..b7d12bca8b744 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/timelines.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/timelines.ts @@ -23,7 +23,7 @@ export const TIMELINE = (id: string | undefined) => { if (id == null) { throw new TypeError('id should never be null or undefined'); } - return `[data-test-subj="title-${id}"]`; + return `[data-test-subj="timeline-title-${id}"]`; }; export const TIMELINE_CHECKBOX = (id: string) => { @@ -36,7 +36,7 @@ export const TIMELINE_ITEM_ACTION_BTN = (id: string) => { export const EXPORT_TIMELINE = '[data-test-subj="export-timeline"]'; -export const TIMELINE_NAME = '[data-test-subj^=title]'; +export const TIMELINE_NAME = '[data-test-subj^=timeline-title-]'; export const TIMELINES_FAVORITE = '[data-test-subj="favorite-starFilled-star"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/discover.ts b/x-pack/test/security_solution_cypress/cypress/tasks/discover.ts index 3da381e1b0af5..90623f239c326 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/discover.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/discover.ts @@ -21,10 +21,16 @@ import { GET_LOCAL_SEARCH_BAR_SUBMIT_BUTTON } from '../screens/search_bar'; export const switchDataViewTo = (dataviewName: string) => { openDataViewSwitcher(); cy.get(DISCOVER_DATA_VIEW_SWITCHER.GET_DATA_VIEW(dataviewName)).trigger('click'); - cy.get(DISCOVER_DATA_VIEW_SWITCHER.INPUT).should('not.be.visible'); + cy.get(DISCOVER_DATA_VIEW_SWITCHER.INPUT).should('not.exist'); cy.get(DISCOVER_DATA_VIEW_SWITCHER.BTN).should('contain.text', dataviewName); }; +export const switchDataViewToESQL = () => { + openDataViewSwitcher(); + cy.get(DISCOVER_DATA_VIEW_SWITCHER.TEXT_BASE_LANG_SWICTHER).trigger('click'); + cy.get(DISCOVER_DATA_VIEW_SWITCHER.BTN).should('contain.text', 'ES|QL'); +}; + export const openDataViewSwitcher = () => { cy.get(DISCOVER_DATA_VIEW_SWITCHER.BTN).click(); cy.get(DISCOVER_DATA_VIEW_SWITCHER.INPUT).should('be.visible'); @@ -38,7 +44,7 @@ export const waitForDiscoverGridToLoad = () => { }; export const addDiscoverKqlQuery = (kqlQuery: string) => { - cy.get(DISCOVER_QUERY_INPUT).type(kqlQuery); + cy.get(DISCOVER_QUERY_INPUT).type(`${kqlQuery}{enter}`); }; export const submitDiscoverSearchBar = () => { diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/stack_management.ts b/x-pack/test/security_solution_cypress/cypress/tasks/stack_management.ts new file mode 100644 index 0000000000000..4c53443c13ae1 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/tasks/stack_management.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SAVED_OBJECTS_SETTINGS } from '../screens/common/stack_management'; + +export const goToSavedObjectSettings = () => { + cy.get(SAVED_OBJECTS_SETTINGS).scrollIntoView(); + cy.get(SAVED_OBJECTS_SETTINGS).should('be.visible').focus(); + cy.get(SAVED_OBJECTS_SETTINGS).should('be.visible').click(); +}; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts b/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts index ced47a6700e7c..6c985c6d93cc1 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts @@ -9,6 +9,7 @@ import { recurse } from 'cypress-recurse'; import type { Timeline, TimelineFilter } from '../objects/timeline'; import { ALL_CASES_CREATE_NEW_CASE_TABLE_BTN } from '../screens/all_cases'; +import { BASIC_TABLE_LOADING } from '../screens/common'; import { FIELDS_BROWSER_CHECKBOX } from '../screens/fields_browser'; import { LOADING_INDICATOR } from '../screens/security_header'; @@ -83,6 +84,9 @@ import { PROVIDER_BADGE, PROVIDER_BADGE_DELETE, DISCOVER_TAB, + OPEN_TIMELINE_MODAL_TIMELINE_NAMES, + OPEN_TIMELINE_MODAL_SEARCH_BAR, + OPEN_TIMELINE_MODAL, } from '../screens/timeline'; import { REFRESH_BUTTON, TIMELINE } from '../screens/timelines'; import { drag, drop } from './common'; @@ -137,8 +141,13 @@ export const goToNotesTab = (): Cypress.Chainable> => { }; export const gotToDiscoverTab = () => { - cy.get(DISCOVER_TAB).click(); - cy.get(DISCOVER_TAB).should('have.class', 'euiTab-isSelected'); + recurse( + () => cy.get(DISCOVER_TAB).click(), + ($el) => expect($el).to.have.class('euiTab-isSelected'), + { + delay: 500, + } + ); }; export const goToCorrelationTab = () => { @@ -487,3 +496,12 @@ export const setKibanaTimezoneToUTC = () => .then(() => { cy.reload(); }); + +export const openTimelineFromOpenTimelineModal = (timelineName: string) => { + cy.get(OPEN_TIMELINE_MODAL_TIMELINE_NAMES).should('have.lengthOf.gt', 0); + cy.get(BASIC_TABLE_LOADING).should('not.exist'); + cy.get(OPEN_TIMELINE_MODAL_SEARCH_BAR).type(`${timelineName}{enter}`); + cy.get(OPEN_TIMELINE_MODAL_TIMELINE_NAMES).should('have.lengthOf', 1); + cy.get(OPEN_TIMELINE_MODAL).should('contain.text', timelineName); + cy.get(OPEN_TIMELINE_MODAL_TIMELINE_NAMES).first().click(); +}; diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/responder.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/responder.ts index 5a0f0fdf93ec1..6a220d35ffffd 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/responder.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/responder.ts @@ -142,6 +142,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { serializedQuery: getEndpointAlertsQueryForAgentId(endpointAgentId).$stringify(), }, }, + savedSearchId: null, }, timeline.data.persistTimeline.timeline.version ); diff --git a/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts b/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts index ce37e487749b7..b6c41a813b07e 100644 --- a/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts +++ b/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts @@ -57,7 +57,7 @@ export class TimelinePageObject extends FtrService { await this.showOpenTimelinePopupFromBottomBar(); await this.testSubjects.click('open-timeline-button'); await this.testSubjects.findService.clickByCssSelector( - `${testSubjSelector('open-timeline-modal')} ${testSubjSelector(`title-${id}`)}` + `${testSubjSelector('open-timeline-modal')} ${testSubjSelector(`timeline-title-${id}`)}` ); await this.ensureTimelineIsOpen(); diff --git a/x-pack/test/security_solution_ftr/services/timeline/index.ts b/x-pack/test/security_solution_ftr/services/timeline/index.ts index 8c87c92d7f6d6..b9acf21a9384e 100644 --- a/x-pack/test/security_solution_ftr/services/timeline/index.ts +++ b/x-pack/test/security_solution_ftr/services/timeline/index.ts @@ -90,6 +90,7 @@ export class TimelineTestService extends FtrService { eventCategoryField: 'event.category', timestampField: '@timestamp', }, + savedSearchId: null, }; // Update the timeline @@ -187,6 +188,7 @@ export class TimelineTestService extends FtrService { serializedQuery: JSON.stringify(esQuery), }, }, + savedSearchId: null, }, newTimeline.data.persistTimeline.timeline.version ); diff --git a/x-pack/test_serverless/functional/test_suites/security/cypress/tasks/login.ts b/x-pack/test_serverless/functional/test_suites/security/cypress/tasks/login.ts index 89ca14ec0c28f..7ff366ea2cd14 100644 --- a/x-pack/test_serverless/functional/test_suites/security/cypress/tasks/login.ts +++ b/x-pack/test_serverless/functional/test_suites/security/cypress/tasks/login.ts @@ -48,7 +48,7 @@ const sendApiLoginRequest = ( }; interface CyLoginTask { - (user?: ServerlessRoleName): ReturnType; + (user?: ServerlessRoleName | 'elastic'): ReturnType; /** * Login using any username/password diff --git a/yarn.lock b/yarn.lock index fadc64b60a170..d85a677823caf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4871,6 +4871,10 @@ version "0.0.0" uid "" +"@kbn/management-settings-components-field-category@link:packages/kbn-management/settings/components/field_category": + version "0.0.0" + uid "" + "@kbn/management-settings-components-field-input@link:packages/kbn-management/settings/components/field_input": version "0.0.0" uid ""